From c6f8e2f91cf7025e8cc728cffeeebbda0e8d3db5 Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Mon, 9 Nov 2026 17:06:45 +0100 Subject: [PATCH 1/5] dx: move test_reports tests to test_reports_tests.rs (part of #10053) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hwESun1HL2eSVd1d5B --- cli/cli/BUILD.bazel | 1 + cli/cli/src/exec/test_reports.rs | 4432 +----------------------- cli/cli/src/exec/test_reports_tests.rs | 4423 +++++++++++++++++++++++ 3 files changed, 4426 insertions(+), 4430 deletions(-) create mode 100644 cli/cli/src/exec/test_reports_tests.rs diff --git a/cli/cli/BUILD.bazel b/cli/cli/BUILD.bazel index 24f6f85254..1d76848e92 100644 --- a/cli/cli/BUILD.bazel +++ b/cli/cli/BUILD.bazel @@ -335,6 +335,7 @@ rust_test( "src/exec/test_case_launcher_tests.rs", "src/exec/test_filter.rs", "src/exec/test_reports.rs", + "src/exec/test_reports_tests.rs", "src/exec/test_support.rs", "src/exec/text_output_tests.rs", "src/exec/umbrella.rs", diff --git a/cli/cli/src/exec/test_reports.rs b/cli/cli/src/exec/test_reports.rs index c4717437f9..ea15809eb2 100644 --- a/cli/cli/src/exec/test_reports.rs +++ b/cli/cli/src/exec/test_reports.rs @@ -1725,4433 +1725,5 @@ pub(crate) fn execute_test_reports(request: TestReportsRequest<'_>) -> i32 { } #[cfg(test)] -mod tests { - #[test] - fn log_tail_keeps_last_forty_lines() { - let text: String = (1..=50).map(|n| format!("line {n}\n")).collect(); - let want: Vec = (11..=50).map(|n| format!("line {n}")).collect(); - assert_eq!(super::log_tail(text.as_bytes()), Some(want.join("\n"))); - } - - #[test] - fn log_tail_returns_short_logs_whole() { - assert_eq!(super::log_tail(b"a\nb\nc\n"), Some("a\nb\nc".to_owned())); - } - - #[test] - fn log_tail_drops_partial_first_line_when_capped() { - let lines: Vec = (0..40) - .map(|n| format!("{n:02}{}", "x".repeat(298))) - .collect(); - let tail = super::log_tail(lines.join("\n").as_bytes()).unwrap(); - assert!(tail.len() <= super::LOG_TAIL_BYTES); - let got: Vec<&str> = tail.split('\n').collect(); - assert!(got.iter().all(|l| l.len() == 300)); - assert_eq!( - got, - lines[40 - got.len()..] - .iter() - .map(String::as_str) - .collect::>() - ); - assert!(!tail.starts_with('\n')); - } - - #[test] - fn log_tail_cuts_one_oversize_multibyte_line_at_char_boundary() { - let tail = super::log_tail("日".repeat(3500).as_bytes()).unwrap(); - assert!(!tail.is_empty() && tail.len() <= super::LOG_TAIL_BYTES); - } - - #[test] - fn log_tail_is_none_for_blank_input() { - assert_eq!(super::log_tail(b""), None); - assert_eq!(super::log_tail(b" \n\t\n\n"), None); - } - - #[test] - fn log_tail_tolerates_invalid_utf8() { - assert!(super::log_tail(b"ok\n\xff\xfe bad\n").is_some()); - } - - use super::super::test_support::*; - use super::stable_exec_path; - use std::path::Path; - - #[test] - fn coverage_sources_returns_the_write_code_when_stdout_fails() { - let account = dx_lcov::CellAccount::default(); - let got = super::coverage_sources( - &super::Sources::default(), - &account, - dx_output::OutputMode::Json, - false, - &mut BrokenPipe, - &mut Vec::new(), - ); - assert_eq!(got, Err(dx_process::broken_pipe_code())); - } - - #[test] - fn an_exec_path_reads_the_same_on_every_host() { - let execroot = "/home/someone/.cache/bazel/_bazel_x/abc123/execroot/_main"; - assert_eq!( - stable_exec_path(Path::new(&format!( - "{execroot}/bazel-out/k8-fastbuild/bin/env/shard/doctor_test/test.xml" - ))), - "env/shard/doctor_test/test.xml" - ); - assert_eq!( - stable_exec_path(Path::new(&format!( - "{execroot}/bazel-out/x64_windows-fastbuild/bin/env/shard/doctor_test/test.xml" - ))), - "env/shard/doctor_test/test.xml", - "the configuration does not reach the report" - ); - assert_eq!( - stable_exec_path(Path::new(&format!("{execroot}/bin/env/shard/test.xml"))), - "env/shard/test.xml" - ); - } - - #[test] - fn a_windows_exec_path_keeps_its_forward_slashes() { - assert_eq!( - stable_exec_path(Path::new( - r"C:/bz/out/execroot/_main/bazel-out/x64_windows-fastbuild/bin/env/shard/test.xml" - )), - "env/shard/test.xml" - ); - } - - #[test] - fn a_path_outside_the_build_directory_keeps_its_file_name() { - assert_eq!( - stable_exec_path(Path::new("/tmp/somewhere/test.xml")), - "test.xml" - ); - assert_eq!(stable_exec_path(Path::new("test.xml")), "test.xml"); - } - - /// A local `file://` URI for a path that is not there, spelled the way this host spells one. - /// - /// Windows only reads a local `file://` URI back as a path when it carries a drive letter, so - /// the rooted spelling every other host accepts would read as a remote host there. - fn missing_uri(name: &str) -> String { - if cfg!(windows) { - format!("file:///C:/nonexistent/{name}") - } else { - format!("file:///nonexistent/{name}") - } - } - - const MINIMAL_TEST_XML: &str = r#""#; - - const MINIMAL_LCOV: &str = "SF:src/a.py\nDA:1,1\nend_of_record\n"; - - #[test] - fn test_junit_file_report_succeeds() { - let harness = Harness::new("test-junit"); - let uri = write_bep_artifact(&harness, "test.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}"); - assert!(out.contains("Wrote junit report to out.xml.")); - let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); - let text = String::from_utf8(document).expect("utf8"); - assert!(text.contains(" Harness { - write_manifest_sources(&harness, manifest); - let uri = write_bep_artifact(&harness, "sources.txt", manifest.as_bytes()); - let mut lines = vec![ - sources_set("s1", &uri), - sources_completed("//a:lib", true, "s1"), - ]; - lines.extend(harness.raw_bep.clone().unwrap_or_default()); - Harness { - raw_bep: Some(lines), - ..harness - } - } - - fn sources_completed(label: &str, success: bool, set: &str) -> String { - let mut completed = serde_json::json!({"success": success}); - if success { - completed["outputGroup"] = - serde_json::json!([{"name": "dx_coverage_sources", "fileSets": [{"id": set}]}]); - } - serde_json::json!({ - "id": {"targetCompleted": {"label": label, "aspect": SOURCES_ASPECT}}, - "completed": completed, - }) - .to_string() - } - - fn sources_set(set: &str, uri: &str) -> String { - serde_json::json!({ - "id": {"namedSet": {"id": set}}, - "namedSetOfFiles": {"files": [{"name": "lib.dx_coverage_sources.txt", "uri": uri}]}, - }) - .to_string() - } - - /// Prepends a manifest owning `src/a.py`, so `--min-coverage` can account the run's sources. - fn owning_a(mut harness: Harness) -> Harness { - write_manifest_sources(&harness, "prod source src/a.py\n"); - let uri = write_bep_artifact( - &harness, - "sources.txt", - b"v1 cell linux_x86_64\nprod source src/a.py\n", - ); - let lines = [ - sources_set("s1", &uri), - sources_completed("//a:lib", true, "s1"), - ]; - if let Some(bep) = harness.raw_bep.as_mut() { - bep.splice(0..0, lines); - } - harness - } - - fn sources_harness(name: &str, manifest: &str, lcov: &str) -> Harness { - let harness = Harness::new(name); - write_manifest_sources(&harness, manifest); - let lcov_uri = write_bep_artifact(&harness, "coverage.dat", lcov.as_bytes()); - let manifest_uri = write_bep_artifact(&harness, "sources.txt", manifest.as_bytes()); - Harness { - raw_bep: Some(vec![ - sources_set("s1", &manifest_uri), - sources_completed("//a:lib", true, "s1"), - test_result_line("//a:t", &[(String::from("test.lcov"), lcov_uri)]), - ]), - ..harness - } - } - - #[test] - fn coverage_absent_production_source_is_a_notice() { - let harness = sources_harness( - "cov-sources-absent", - "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", - MINIMAL_LCOV, - ); - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!( - err.contains( - "dx: coverage_sources_incomplete: production source absent from coverage report: src/b.py" - ), - "{err}" - ); - } - - #[test] - fn coverage_sources_report_lists_owned_and_absent() { - let harness = sources_harness( - "cov-sources-report", - "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\ntest source src/a_test.py\n", - MINIMAL_LCOV, - ); - let path = harness.workspace.join("sources.json"); - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - &format!("--report=sources={}", path.display()), - ]); - assert_eq!(code, 0, "{err}"); - let document: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).expect("sources report")).unwrap(); - assert_eq!(document["record_version"], serde_json::json!(2)); - assert_eq!(document["orphans"], serde_json::json!([])); - assert_eq!(document["absent"], serde_json::json!(["src/b.py"])); - assert_eq!(document["owned"]["src/a.py"], serde_json::json!("eligible")); - assert_eq!( - document["owned"]["src/a_test.py"], - serde_json::json!("support") - ); - } - - const ORPHAN_MANIFEST: &str = "v1 cell linux_x86_64\nprod source src/a.py\n"; - const ORPHAN_MESSAGE: &str = "tracked source not owned by any Bazel target: src/orphan.py"; - - /// Scripts a clean Git checkout whose tracked files include a used and an unused source. - fn script_checkout(harness: &Harness) { - std::fs::create_dir_all(harness.workspace.join("src")).expect("mkdir"); - std::fs::write(harness.workspace.join("src/BUILD.bazel"), "").expect("build"); - harness.query.script_checkout_git(0, "true\n"); - harness.query.script_checkout_git(0, "abc123\n"); - harness.query.script_checkout_git(0, ""); - harness.query.script_checkout_git(0, "true\n"); - harness - .query - .script_checkout_git(0, "src/a.py\0src/orphan.py\0src/BUILD.bazel\0"); - harness.query.script_graph(&[dx_testing::cli::graph_rule( - "//src:lib", - &["//src:a.py"], - &[], - )]); - } - - #[test] - fn coverage_tracked_orphan_fails_min_coverage() { - let harness = sources_harness("cov-orphan-min", ORPHAN_MANIFEST, MINIMAL_LCOV); - script_checkout(&harness); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains(ORPHAN_MESSAGE), "{err}"); - } - - #[test] - fn coverage_tracked_orphan_is_a_notice_without_minimum() { - let harness = sources_harness("cov-orphan-notice", ORPHAN_MANIFEST, MINIMAL_LCOV); - script_checkout(&harness); - let path = harness.workspace.join("sources.json"); - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - &format!("--report=sources={}", path.display()), - ]); - assert_eq!(code, 0, "{err}"); - assert!(err.contains(ORPHAN_MESSAGE), "{err}"); - let document: serde_json::Value = - serde_json::from_slice(&std::fs::read(&path).expect("sources report")).unwrap(); - assert_eq!(document["orphans"], serde_json::json!(["src/orphan.py"])); - assert_eq!( - document["candidate"], - serde_json::json!({"sha": "abc123", "dirty": false}) - ); - } - - #[test] - fn coverage_tracked_check_skips_scoped_runs() { - let harness = sources_harness("cov-orphan-scoped", ORPHAN_MANIFEST, MINIMAL_LCOV); - script_checkout(&harness); - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - "--min-coverage=100", - "//src:lib", - ]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains(ORPHAN_MESSAGE), "{err}"); - } - - #[test] - fn coverage_tracked_check_skips_non_git_workspace() { - let harness = sources_harness("cov-orphan-nogit", ORPHAN_MANIFEST, MINIMAL_LCOV); - harness.query.script_checkout_git(128, ""); - harness.query.script_checkout_git(128, ""); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 0, "{err}"); - assert!( - err.contains("dx: tracked sources unavailable: not a Git work tree"), - "{err}" - ); - } - - #[test] - fn coverage_tracked_query_failure_fails_min_coverage() { - let harness = sources_harness("cov-orphan-query", ORPHAN_MANIFEST, MINIMAL_LCOV); - script_checkout(&harness); - harness.query.outputs.borrow_mut().clear(); - harness.query.script_failure(1, "ERROR: boom\n"); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("tracked sources unavailable:"), "{err}"); - } - - #[test] - fn coverage_event_carries_sha_dirty_and_collector() { - let mut harness = sources_harness("cov-event-candidate", ORPHAN_MANIFEST, MINIMAL_LCOV); - script_checkout(&harness); - harness.raw_bep.as_mut().expect("bep").insert( - 0, - r#"{"id":{"started":{}},"started":{"buildToolVersion":"9.0.0"}}"#.to_owned(), - ); - let (code, out, err) = harness.run(&["coverage", "--output=json"]); - assert_eq!(code, 0, "{err}"); - let event = out - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|event| event["event"] == "coverage") - .expect("coverage event"); - assert_eq!(event["sha"], "abc123"); - assert_eq!(event["dirty"], false); - assert_eq!(event["collector"], "bazel 9.0.0"); - } - - #[test] - fn coverage_zero_hit_line_fails_min_coverage() { - let harness = sources_harness("cov-sources-zero-hit", PY_SOURCES, HALF_LCOV); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains( - "dx: coverage 50.00% (1/2 lines) below minimum 100% in package linux_x86_64 (coverage_below_minimum)" - ), - "{err}" - ); - } - - #[test] - fn coverage_report_without_manifests_fails_min_coverage() { - let harness = coverage_harness("cov-sources-none", MINIMAL_LCOV.as_bytes()); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("coverage_below_minimum"), "{err}"); - assert!( - err.contains("no coverage source manifests were reported"), - "{err}" - ); - } - - #[test] - fn coverage_event_reports_the_cell_account() { - let harness = sources_harness( - "cov-sources-event", - "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", - MINIMAL_LCOV, - ); - let (code, out, _) = harness.run(&["coverage", "--output=json", "--min-coverage=100"]); - assert_eq!(code, 1, "{out}"); - let events: Vec = out - .lines() - .filter_map(|line| serde_json::from_str(line).ok()) - .collect(); - let coverage: Vec<&serde_json::Value> = events - .iter() - .filter(|event| event["event"] == "coverage") - .collect(); - assert_eq!(coverage.len(), 1, "{out}"); - let event = coverage[0]; - assert_eq!(event["cell"], "linux_x86_64", "{event}"); - assert_eq!(event["covered_lines"], 1, "{event}"); - assert_eq!(event["eligible_lines"], 1, "{event}"); - assert_eq!(event["minimum"], 100, "{event}"); - assert_eq!(event["result"], "fail", "{event}"); - assert_eq!(event["errors"], 1, "{event}"); - assert_eq!(event["dx_version"], dx_adopt::DX_VERSION, "{event}"); - assert!( - events - .iter() - .any(|event| event["code"] == "coverage_below_minimum"), - "{out}" - ); - } - - #[test] - fn coverage_event_passes_a_clean_cell_without_minimum() { - let harness = sources_harness("cov-sources-event-clean", PY_SOURCES, MINIMAL_LCOV); - let (code, out, _) = harness.run(&["coverage", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let event = out - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|event| event["event"] == "coverage") - .unwrap_or_else(|| panic!("no coverage event: {out}")); - assert_eq!(event["result"], "pass", "{event}"); - assert!(event.get("minimum").is_none(), "{event}"); - } - - #[test] - fn coverage_sources_counts_line_and_silent_test_sources() { - let harness = sources_harness( - "cov-sources-counts", - "v1 cell linux_x86_64\nprod source src/a.py\ntest source src/a_test.py\n", - MINIMAL_LCOV, - ); - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!( - err.contains( - "dx: coverage sources linux_x86_64: 1 raw, 0 excluded, 1 eligible, 1 covered lines; 1 eligible, 1 support, 0 excluded, 0 unsupported files" - ), - "{err}" - ); - assert!(!err.contains("coverage_sources_incomplete"), "{err}"); - } - - #[test] - fn coverage_sources_counts_perl_sources() { - let harness = sources_harness( - "cov-sources-perl", - "v1 cell linux_x86_64\nprod source src/a.pm\ntest source src/a_test.pl\n", - "SF:src/a.pm\nDA:1,1\nend_of_record\n", - ); - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!( - err.contains("1 eligible, 1 covered lines; 1 eligible, 1 support, 0 excluded, 0 unsupported files"), - "{err}" - ); - } - - #[test] - fn coverage_sources_notice_is_a_json_event() { - let harness = sources_harness( - "cov-sources-json", - "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", - MINIMAL_LCOV, - ); - let (code, out, _) = harness.run(&["coverage", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let notice = out - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|event| event["code"] == "coverage_sources_incomplete") - .unwrap_or_else(|| panic!("no notice: {out}")); - assert_eq!(notice["event"], "notice", "{notice}"); - assert_eq!(notice["severity"], "warning", "{notice}"); - assert_eq!( - notice["message"], - "production source absent from coverage report: src/b.py" - ); - let counts = out - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|event| event["code"] == "coverage_sources") - .unwrap_or_else(|| panic!("no counts notice: {out}")); - assert_eq!(counts["severity"], "info", "{counts}"); - assert!( - counts["message"] - .as_str() - .is_some_and(|message| message.starts_with("coverage sources linux_x86_64: ")), - "{counts}" - ); - } - - #[test] - fn coverage_invalid_source_manifest_is_a_notice() { - let harness = sources_harness("cov-sources-invalid", "v1 cell unknown\n", MINIMAL_LCOV); - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!( - err.contains("dx: coverage_sources_incomplete: invalid coverage source manifest"), - "{err}" - ); - assert!(err.contains("unknown cell"), "{err}"); - } - - #[test] - fn coverage_absent_production_source_fails_min_coverage() { - let harness = sources_harness( - "cov-sources-absent-gate", - "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", - MINIMAL_LCOV, - ); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!(!err.contains("meets minimum"), "{err}"); - assert!( - err.contains( - "dx: coverage 100.00% (1/1 lines) in package linux_x86_64 has 1 coverage source gap (see" - ), - "{err}" - ); - assert!( - err.contains( - "dx: coverage_sources_incomplete: production source absent from coverage report: src/b.py" - ), - "{err}" - ); - } - - #[test] - fn coverage_unowned_report_path_fails_min_coverage() { - let harness = sources_harness( - "cov-sources-unowned-gate", - "v1 cell linux_x86_64\nprod source src/other.py\n", - "SF:src/a.py\nDA:1,1\nend_of_record\nSF:src/other.py\nDA:1,1\nend_of_record\n", - ); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains( - "dx: coverage_sources_incomplete: coverage report path not owned by any target: src/a.py" - ), - "{err}" - ); - } - - #[test] - fn coverage_invalid_source_manifest_fails_min_coverage() { - let harness = sources_harness( - "cov-sources-invalid-gate", - "v1 cell unknown\n", - MINIMAL_LCOV, - ); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("dx: coverage_sources_incomplete: invalid coverage source manifest"), - "{err}" - ); - } - - #[test] - fn coverage_sources_error_is_a_json_error_under_min_coverage() { - let harness = sources_harness( - "cov-sources-json-gate", - "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", - MINIMAL_LCOV, - ); - let (code, out, _) = harness.run(&["coverage", "--output=json", "--min-coverage=100"]); - assert_eq!(code, 1, "{out}"); - let error = out - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|event| event["code"] == "coverage_sources_incomplete") - .unwrap_or_else(|| panic!("no error: {out}")); - assert_eq!(error["event"], "error", "{error}"); - assert_eq!( - error["message"], - "production source absent from coverage report: src/b.py" - ); - } - - #[test] - fn coverage_test_only_sources_pass_min_coverage() { - let harness = sources_harness( - "cov-sources-test-only-gate", - "v1 cell linux_x86_64\nprod source src/a.py\ntest source src/a_test.py\n", - MINIMAL_LCOV, - ); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("coverage_sources_incomplete"), "{err}"); - } - - #[test] - fn coverage_missing_source_manifest_blocks_min_coverage() { - let harness = Harness::new("cov-sources-missing"); - let lcov_uri = write_bep_artifact(&harness, "coverage.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - sources_completed("//a:lib", false, "s1"), - test_result_line("//a:t", &[(String::from("test.lcov"), lcov_uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains( - "dx: incomplete_results: //a:lib did not report its coverage source manifest" - ), - "{err}" - ); - assert!(!err.contains("meets minimum"), "{err}"); - } - - #[test] - fn coverage_unreadable_source_manifest_is_incomplete() { - let harness = Harness::new("cov-sources-unreadable"); - let lcov_uri = write_bep_artifact(&harness, "coverage.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - sources_set("s1", &missing_uri("sources.txt")), - sources_completed("//a:lib", true, "s1"), - test_result_line("//a:t", &[(String::from("test.lcov"), lcov_uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("coverage source manifests are unreadable"), - "{err}" - ); - } - - #[test] - fn test_unreadable_bep_is_operational() { - let mut harness = Harness::new("test-nobep"); - harness.skip_bep = true; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("unreadable_bep"), "{err}"); - } - - #[test] - fn test_invalid_bep_is_operational() { - let harness = Harness { - raw_bep: Some(vec![String::from("not json")]), - ..Harness::new("test-badbep") - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("invalid_bep"), "{err}"); - } - - #[test] - fn test_missing_bep_keeps_bazel_code() { - let mut harness = Harness::new("test-nobep-failed"); - harness.skip_bep = true; - harness.bazel_code = 2; - let (code, out, _) = harness.run(&[ - "test", - "--output=json", - "--report=results=r.json", - "--report=junit=j.xml", - ]); - assert_eq!(code, 2, "{out}"); - let events = json_events(&out); - assert!( - events_of_kind(&events, "notice") - .iter() - .any(|event| event["code"] == "build_events_incomplete"), - "{out}" - ); - let errors: Vec<_> = events_of_kind(&events, "error") - .into_iter() - .map(|event| event["code"].as_str().unwrap_or_default().to_owned()) - .collect(); - assert!(errors.contains(&"incomplete_results".to_owned()), "{out}"); - assert!(errors.contains(&"bazel_failed".to_owned()), "{out}"); - assert!(!out.contains("unreadable_bep"), "{out}"); - assert_eq!( - event(&events, "command_finished")["exit_code"], - serde_json::json!(2) - ); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("r.json")).expect("results report"), - ) - .expect("results json"); - assert_eq!(document["exit_code"], serde_json::json!(2)); - assert_eq!(document["complete"], serde_json::json!(false)); - assert_eq!(document["results"], serde_json::json!([])); - let junit = std::fs::read_to_string(harness.workspace.join("j.xml")).expect("junit"); - assert!(junit.contains("dx.infrastructure"), "{junit}"); - assert!(junit.contains("build events are unavailable"), "{junit}"); - } - - #[test] - fn test_cut_off_bep_keeps_bazel_code_and_results() { - let harness = Harness::new("test-cutbep-failed"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - bazel_code: 37, - unterminated_bep: true, - raw_bep: Some(vec![ - test_result_line("//a:t", &[(String::from("test.xml"), uri)]), - String::from(r#"{"id":{"testRes"#), - ]), - ..harness - }; - let (code, out, _) = harness.run(&[ - "test", - "--output=json", - "--report=results=r.json", - "--report=junit=j.xml", - ]); - assert_eq!(code, 37, "{out}"); - assert!(!out.contains("invalid_bep"), "{out}"); - let events = json_events(&out); - let incomplete = events_of_kind(&events, "error") - .into_iter() - .find(|event| event["code"] == "incomplete_results") - .expect("incomplete_results"); - assert!( - incomplete["message"] - .as_str() - .is_some_and(|message| message.contains("build events end early: line 2")), - "{out}" - ); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("r.json")).expect("results report"), - ) - .expect("results json"); - assert_eq!(document["exit_code"], serde_json::json!(37)); - assert_eq!(document["complete"], serde_json::json!(false)); - let results = document["results"].as_array().expect("results"); - assert_eq!(results.len(), 1, "{document}"); - assert_eq!(results[0]["target"], serde_json::json!("//a:t")); - let junit = std::fs::read_to_string(harness.workspace.join("j.xml")).expect("junit"); - assert!(junit.contains(">(); - let mut lines = raw; - for index in 0..2 { - lines.push(test_result_line( - &format!("//a:missing{index}"), - &[( - String::from("test.xml"), - missing_uri(&format!("fraction{index}.xml")), - )], - )); - } - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("incomplete_results: 2 missing or invalid results"), - "{err}" - ); - } - - #[test] - fn test_large_missing_fraction_fails_when_bazel_passed() { - let harness = Harness::new("test-partial-majority"); - let raw = (0..2) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("test.xml"), - write_bep_artifact( - &harness, - &format!("ok6-{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - for index in 0..5 { - lines.push(test_result_line( - &format!("//a:missing{index}"), - &[( - String::from("test.xml"), - missing_uri(&format!("majority{index}.xml")), - )], - )); - } - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("5 missing or invalid results"), "{err}"); - } - - #[test] - fn test_one_missing_and_one_invalid_xml_fails_when_bazel_passed() { - let harness = Harness::new("test-mixed-two"); - let raw = (0..6) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("test.xml"), - write_bep_artifact( - &harness, - &format!("ok4-{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - lines.push(test_result_line( - "//a:bad", - &[( - String::from("test.xml"), - write_bep_artifact(&harness, "bad2.xml", b"not xml"), - )], - )); - lines.push(test_result_line( - "//a:missing", - &[(String::from("test.xml"), missing_uri("missing5.xml"))], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("incomplete_results: 2 missing or invalid results"), - "{err}" - ); - } - - #[test] - fn test_single_missing_xml_fails_when_bazel_failed() { - let harness = Harness::new("test-partial-bazelfail"); - let uri = write_bep_artifact(&harness, "ok2.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:ok", &[(String::from("test.xml"), uri)]), - test_result_line( - "//a:missing", - &[(String::from("test.xml"), missing_uri("missing2.xml"))], - ), - ]), - bazel_code: 1, - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn test_invalid_xml_is_incomplete() { - let harness = Harness::new("test-badxml"); - let uri = write_bep_artifact(&harness, "bad.xml", b"not xml"); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn coverage_ignores_non_lcov_and_reports_missing() { - let harness = Harness::new("cov-ignore"); - let xml = write_bep_artifact(&harness, "x.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), xml)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("no coverage.dat"), "{err}"); - } - - #[test] - fn coverage_report_without_lcov_names_the_gap_not_the_destination() { - let harness = Harness::new("cov-gap-report"); - let xml = write_bep_artifact(&harness, "x.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), xml)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("report_failed"), "{err}"); - assert!(err.contains("no coverage.dat"), "{err}"); - assert!(err.contains("fix the test or coverage problem"), "{err}"); - assert!(!err.contains("writable"), "{err}"); - } - - #[test] - fn coverage_unreadable_is_incomplete() { - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("coverage.dat"), missing_uri("c.dat"))], - )]), - ..Harness::new("cov-unreadable") - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn coverage_single_missing_dat_fails_when_bazel_passed() { - let harness = Harness::new("cov-partial-missing"); - let raw = (0..4) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("coverage.dat"), - write_bep_artifact( - &harness, - &format!("ok7-{index}.dat"), - MINIMAL_LCOV.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - lines.push(test_result_line( - "//a:missing", - &[(String::from("coverage.dat"), missing_uri("missing.dat"))], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!( - err.contains("coverage.dat for //a:missing (run 1, shard 1, attempt 1)"), - "{err}" - ); - } - - #[test] - fn coverage_missing_artifact_blocks_min_coverage() { - let harness = Harness::new("cov-missing-threshold"); - let raw = (0..4) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("coverage.dat"), - write_bep_artifact( - &harness, - &format!("ok8-{index}.dat"), - MINIMAL_LCOV.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - lines.push(test_result_line( - "//a:missing", - &[(String::from("coverage.dat"), missing_uri("missing.dat"))], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!(!err.contains("meets minimum"), "{err}"); - } - - #[test] - fn coverage_invalid_artifact_blocks_min_coverage() { - let harness = Harness::new("cov-invalid-threshold"); - let ok_uri = write_bep_artifact(&harness, "ok.dat", MINIMAL_LCOV.as_bytes()); - let bad_uri = write_bep_artifact(&harness, "bad.dat", b"not lcov"); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:ok", &[(String::from("coverage.dat"), ok_uri)]), - test_result_line("//a:bad", &[(String::from("coverage.dat"), bad_uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!( - err.contains("invalid coverage.dat for //a:bad (run 1, shard 1, attempt 1)"), - "{err}" - ); - assert!(!err.contains("meets minimum"), "{err}"); - } - - #[test] - fn coverage_superseded_attempt_is_not_required() { - let harness = Harness::new("cov-retry-ok"); - let final_uri = write_bep_artifact(&harness, "final.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_identity_line( - "//a:t", - 1, - 1, - 1, - &[(String::from("test.lcov"), missing_uri("attempt1.dat"))], - ), - test_result_identity_line( - "//a:t", - 1, - 1, - 2, - &[(String::from("test.lcov"), final_uri)], - ), - ]), - ..harness - }; - let harness = owning_a(harness); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("incomplete_results"), "{err}"); - assert!(err.contains("meets minimum 100%"), "{err}"); - } - - #[test] - fn coverage_final_attempt_missing_is_required() { - let harness = Harness::new("cov-retry-missing"); - let stale_uri = write_bep_artifact(&harness, "stale.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_identity_line( - "//a:t", - 1, - 1, - 1, - &[(String::from("test.lcov"), stale_uri)], - ), - test_result_identity_line( - "//a:t", - 1, - 1, - 2, - &[(String::from("test.lcov"), missing_uri("final.dat"))], - ), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!( - err.contains("test.lcov for //a:t (run 1, shard 1, attempt 2)"), - "{err}" - ); - } - - #[test] - fn coverage_uninstrumented_test_does_not_demand_coverage() { - let harness = Harness::new("cov-uninstrumented"); - let lcov_uri = write_bep_artifact(&harness, "inst.dat", MINIMAL_LCOV.as_bytes()); - let xml_uri = write_bep_artifact(&harness, "plain.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:instrumented", &[(String::from("test.lcov"), lcov_uri)]), - test_result_line("//a:plain", &[(String::from("test.xml"), xml_uri)]), - ]), - ..harness - }; - let harness = owning_a(harness); - let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("incomplete_results"), "{err}"); - assert!(err.contains("meets minimum 100%"), "{err}"); - } - - #[test] - fn coverage_partial_report_is_written_and_marked_incomplete() { - let harness = Harness::new("cov-partial-report"); - let ok_uri = write_bep_artifact(&harness, "ok.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:ok", &[(String::from("coverage.dat"), ok_uri)]), - test_result_line( - "//a:missing", - &[(String::from("coverage.dat"), missing_uri("missing.dat"))], - ), - ]), - ..harness - }; - let (code, out, err) = - harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); - assert_eq!(code, 1, "{out}{err}"); - assert!(out.contains("Wrote lcov report to out.lcov."), "{out}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!( - err.contains("coverage.dat for //a:missing"), - "the partial report names the missing target artifact: {err}" - ); - let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); - assert_eq!(document, MINIMAL_LCOV.as_bytes()); - } - - #[test] - fn coverage_incomplete_json_agrees_with_exit_status() { - let harness = Harness::new("cov-incomplete-json"); - let ok_uri = write_bep_artifact(&harness, "ok.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:ok", &[(String::from("coverage.dat"), ok_uri)]), - test_result_line( - "//a:missing", - &[(String::from("coverage.dat"), missing_uri("missing.dat"))], - ), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["coverage", "--output=json", "--min-coverage=100"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("incomplete_results"), "{out}"); - assert!(out.contains("\"results_complete\":false"), "{out}"); - assert!(!out.contains("coverage_below_minimum"), "{out}"); - } - - #[test] - fn coverage_two_missing_dats_fail_when_bazel_passed() { - let harness = Harness::new("cov-partial-two"); - let raw = (0..6) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("coverage.dat"), - write_bep_artifact( - &harness, - &format!("ok3-{index}.dat"), - MINIMAL_LCOV.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - for index in 0..2 { - lines.push(test_result_line( - &format!("//a:missing{index}"), - &[( - String::from("coverage.dat"), - missing_uri(&format!("missing3-{index}.dat")), - )], - )); - } - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!(err.contains("2 missing or invalid results"), "{err}"); - } - - #[test] - fn coverage_one_invalid_and_one_missing_dat_fail_when_bazel_passed() { - let harness = Harness::new("cov-mixed-two"); - let raw = (0..6) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("test.lcov"), - write_bep_artifact( - &harness, - &format!("ok4-{index}.dat"), - MINIMAL_LCOV.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - lines.push(test_result_line( - "//a:bad", - &[( - String::from("test.lcov"), - write_bep_artifact(&harness, "bad3.dat", b"not lcov"), - )], - )); - lines.push(test_result_line( - "//a:missing", - &[(String::from("coverage.dat"), missing_uri("missing5.dat"))], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - assert!(err.contains("2 missing or invalid results"), "{err}"); - } - - #[test] - fn coverage_large_missing_fraction_fails_when_bazel_passed() { - let harness = Harness::new("cov-partial-majority"); - let raw = (0..2) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("coverage.dat"), - write_bep_artifact( - &harness, - &format!("ok6-{index}.dat"), - MINIMAL_LCOV.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - for index in 0..5 { - lines.push(test_result_line( - &format!("//a:missing{index}"), - &[( - String::from("coverage.dat"), - missing_uri(&format!("covmajority{index}.dat")), - )], - )); - } - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("5 missing or invalid results"), "{err}"); - } - - #[test] - fn coverage_empty_dat_does_not_count_as_missing() { - let harness = Harness::new("cov-empty-ok"); - let empty = write_bep_artifact(&harness, "empty2.dat", b""); - let ok = write_bep_artifact(&harness, "ok5.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:empty", &[(String::from("coverage.dat"), empty)]), - test_result_line("//a:ok", &[(String::from("coverage.dat"), ok)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn coverage_single_missing_dat_fails_when_bazel_failed() { - let harness = Harness::new("cov-partial-bazelfail"); - let uri = write_bep_artifact(&harness, "ok2.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:ok", &[(String::from("coverage.dat"), uri)]), - test_result_line( - "//a:missing", - &[(String::from("coverage.dat"), missing_uri("missing2.dat"))], - ), - ]), - bazel_code: 1, - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn coverage_report_failed_when_incomplete() { - let harness = Harness::new("cov-repfail"); - let uri = write_bep_artifact(&harness, "bad2.dat", b"not lcov"); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.lcov"), uri)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("report_failed"), "{err}"); - } - - #[test] - fn test_report_write_failure_is_operational() { - let harness = Harness::new("test-writefail"); - let uri = write_bep_artifact(&harness, "w.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&[ - "test", - "--output=text", - "--report=junit=missing-dir/out.xml", - ]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("report_failed"), "{err}"); - } - - #[test] - fn test_report_json_emits_report_event() { - let harness = Harness::new("test-jsonrep"); - let uri = write_bep_artifact(&harness, "j.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}"); - assert!(out.contains("report"), "{out}"); - assert!(out.contains("command_finished"), "{out}"); - } - - #[test] - fn test_rejects_diff_output_at_parse() { - let err = dx_plan::args::parse( - &["test", "--diff", "--report=junit=out.xml"] - .iter() - .map(ToString::to_string) - .collect::>(), - ) - .expect_err("diff rejected"); - assert_eq!( - err, - dx_plan::args::ArgsError::UnsupportedOption { - command: "test", - option: "--diff".to_owned(), - } - ); - } - - #[test] - fn test_incomplete_json_reports_incomplete_event() { - let harness = Harness { - raw_bep: Some(vec![String::from( - "{\"id\": {\"testResult\": {\"label\": \"//a:t\"}}, \"testResult\": {\"status\": \"PASSED\"}}", - )]), - ..Harness::new("test-incjson") - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("incomplete_results"), "{out}"); - assert!(out.contains("command_finished"), "{out}"); - } - - #[test] - fn coverage_lcov_without_trailing_newline_gets_newline() { - let harness = Harness::new("cov-nonl"); - let raw = b"SF:src/a.py\nDA:1,1\nend_of_record"; - let uri = write_bep_artifact(&harness, "nonl.dat", raw); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.lcov"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); - assert_eq!(code, 0, "{out}"); - let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); - assert!(document.ends_with(b"\n"), "{document:?}"); - } - - #[test] - fn coverage_render_failure_json_reports_error_event() { - let harness = Harness::new("cov-render-json"); - let xml = write_bep_artifact(&harness, "x.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), xml)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["coverage", "--output=json", "--report=lcov=out.lcov"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("report_failed"), "{out}"); - } - - #[test] - fn test_report_write_failure_json_reports_error_event() { - let harness = Harness::new("test-writefail-json"); - let uri = write_bep_artifact(&harness, "w2.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&[ - "test", - "--output=json", - "--report=junit=missing-dir/out.xml", - ]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("report_failed"), "{out}"); - } - - #[test] - fn a_testlogs_path_keeps_its_package_and_identity() { - let execroot = "/home/someone/.cache/bazel/_bazel_x/abc123/execroot/_main"; - assert_eq!( - stable_exec_path(Path::new(&format!( - "{execroot}/bazel-out/k8-fastbuild/testlogs/cli/bep/dx_bep_test/run_2_of_2/test.xml" - ))), - "cli/bep/dx_bep_test/run_2_of_2/test.xml" - ); - assert_eq!( - stable_exec_path(Path::new("/ws/bazel-testlogs/a/t/shard_1_of_2/test.xml")), - "a/t/shard_1_of_2/test.xml" - ); - } - - #[test] - fn test_file_outputs_skip_bazel_info() { - let harness = Harness::new("test-file-no-info"); - let uri = write_bep_artifact(&harness, "test.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}{err}"); - let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); - let text = String::from_utf8(document).expect("utf8"); - assert!(text.contains("tests=\"1\""), "{text}"); - assert!(harness.query.info_calls.borrow().is_empty()); - } - - #[test] - fn test_bytestream_outputs_resolve_through_info_testlogs() { - let harness = Harness::new("test-info-roots"); - let testlogs = harness.temp.join("testlogs"); - harness.query.script_info(&format!( - "bazel-testlogs: {}\nexecution_root: {}\nignored: {}\n", - testlogs.display(), - harness.temp.join("execroot").display(), - harness.temp.display(), - )); - let root = testlogs.join("a").join("t"); - std::fs::create_dir_all(&root).expect("testlogs dir"); - std::fs::write(root.join("test.xml"), MINIMAL_TEST_XML).expect("test.xml"); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[( - String::from("test.xml"), - "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), - )], - )]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}{err}"); - let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); - let text = String::from_utf8(document).expect("utf8"); - assert!(text.contains("tests=\"1\""), "{text}"); - let calls = harness.query.info_calls.borrow(); - assert_eq!(calls.len(), 1, "one bazel info run per report run"); - let argv = &calls[0]; - assert!(argv.contains(&"info".to_owned()), "{argv:?}"); - assert!(argv.contains(&"bazel-testlogs".to_owned()), "{argv:?}"); - assert!(argv.contains(&"execution_root".to_owned()), "{argv:?}"); - assert!(argv.contains(&"output_path".to_owned()), "{argv:?}"); - assert!( - argv.contains(&"--config=dx_build_dev".to_owned()), - "the test profile reaches info: {argv:?}" - ); - assert!( - harness.query.calls.borrow().is_empty(), - "info must not consume query outputs" - ); - } - - #[test] - fn test_bytestream_outputs_resolve_through_configuration_mnemonic() { - let harness = Harness::new("test-info-mnemonic"); - let out = harness.temp.join("out"); - harness.query.script_info(&format!( - "bazel-testlogs: {}\nexecution_root: {}\noutput_path: {}\n", - out.join("k8-fastbuild").join("testlogs").display(), - harness.temp.join("execroot").display(), - out.display(), - )); - let root = out - .join("k8-fastbuild-ST-x") - .join("testlogs") - .join("a") - .join("t"); - std::fs::create_dir_all(&root).expect("testlogs dir"); - std::fs::write(root.join("test.xml"), MINIMAL_TEST_XML).expect("test.xml"); - let configuration = serde_json::json!({ - "id": {"configuration": {"id": "cfgST"}}, - "configuration": {"mnemonic": "k8-fastbuild-ST-x"}, - }) - .to_string(); - let harness = Harness { - raw_bep: Some(vec![ - configuration, - test_result_full_line( - "//a:t", - 1, - 1, - 1, - "PASSED", - Some("cfgST"), - None, - None, - &[( - String::from("test.xml"), - "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), - )], - ), - ]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=json", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}{err}"); - assert!(!out.contains("incomplete_results"), "{out}"); - let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); - let text = String::from_utf8(document).expect("utf8"); - assert!(text.contains("tests=\"1\""), "{text}"); - } - - #[test] - fn test_info_carries_startup_options_before_the_verb() { - let harness = Harness::new("test-info-startup"); - let testlogs = harness.temp.join("testlogs"); - harness.query.script_info(&format!( - "bazel-testlogs: {}\nexecution_root: {}\n", - testlogs.display(), - harness.temp.join("execroot").display(), - )); - let root = testlogs.join("a").join("t"); - std::fs::create_dir_all(&root).expect("testlogs dir"); - std::fs::write(root.join("test.xml"), MINIMAL_TEST_XML).expect("test.xml"); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[( - String::from("test.xml"), - "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), - )], - )]), - ..harness - }; - let (code, _, err) = harness.run(&[ - "test", - "--bazel-startup-option=--output_base=/tmp/a", - "--output=text", - ]); - assert_eq!(code, 0, "{err}"); - let calls = harness.query.info_calls.borrow(); - assert_eq!(calls.len(), 1, "one bazel info run per report run"); - let argv = &calls[0]; - let verb = argv.iter().position(|arg| arg == "info").expect("info"); - assert_eq!( - &argv[..verb], - &[ - "bazel".to_owned(), - "--nohome_rc".to_owned(), - "--nosystem_rc".to_owned(), - "--output_base=/tmp/a".to_owned(), - ] - ); - } - - #[test] - fn test_sharded_and_retried_bytestream_outputs_keep_identity() { - let harness = Harness::new("test-info-identity"); - let testlogs = harness.temp.join("testlogs"); - harness - .query - .script_info(&format!("bazel-testlogs: {}\n", testlogs.display())); - let unit = testlogs.join("a").join("t"); - let attempts = unit.join("shard_1_of_2").join("test_attempts"); - std::fs::create_dir_all(&attempts).expect("attempt dir"); - std::fs::create_dir_all(unit.join("shard_2_of_2")).expect("shard dir"); - std::fs::write(attempts.join("attempt_1.xml"), MINIMAL_TEST_XML).expect("attempt xml"); - std::fs::write(unit.join("shard_1_of_2").join("test.xml"), MINIMAL_TEST_XML) - .expect("shard xml"); - std::fs::write(unit.join("shard_2_of_2").join("test.xml"), MINIMAL_TEST_XML) - .expect("shard xml"); - let entry = || { - vec![( - String::from("test.xml"), - "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), - )] - }; - let harness = Harness { - raw_bep: Some(vec![ - test_summary_line("//a:t", 2), - test_result_identity_line("//a:t", 1, 1, 1, &entry()), - test_result_identity_line("//a:t", 1, 1, 2, &entry()), - test_result_identity_line("//a:t", 1, 2, 1, &entry()), - ]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}{err}"); - let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); - let text = String::from_utf8(document).expect("utf8"); - assert!(text.contains("tests=\"3\""), "{text}"); - } - - #[test] - fn two_runs_of_one_label_render_distinct_case_names() { - let harness = Harness::new("test-junit-runs"); - let first = write_bep_artifact(&harness, "run1.xml", MINIMAL_TEST_XML.as_bytes()); - let second = write_bep_artifact(&harness, "run2.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_identity_line("//a:t", 1, 1, 1, &[(String::from("test.xml"), first)]), - test_result_identity_line("//a:t", 2, 1, 1, &[(String::from("test.xml"), second)]), - ]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}{err}"); - let text = std::fs::read_to_string(harness.workspace.join("out.xml")).expect("junit"); - assert!( - text.contains(r#"\xff"; - let uri = write_bep_artifact(&harness, "test.xml", xml); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - err.contains( - "dx: warning: test.xml for //a:t (run 1, shard 1, attempt 1) has invalid UTF-8" - ), - "{err}" - ); - let text = std::fs::read_to_string(harness.workspace.join("out.xml")).expect("junit"); - assert!(text.contains(r#"tt"#; - - const EMPTY_TEST_XML: &str = r#""#; - - #[test] - fn test_outcomes_carry_bep_status_cache_and_timing() { - let harness = Harness::new("test-outcome-meta"); - let ok_uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let cached_uri = write_bep_artifact(&harness, "cached.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_full_line( - "//a:ok", - 1, - 1, - 1, - "PASSED", - Some("cfg-one"), - Some(false), - Some(12), - &[(String::from("test.xml"), ok_uri)], - ), - test_result_full_line( - "//a:cached", - 2, - 3, - 1, - "FAILED", - None, - Some(true), - None, - &[(String::from("test.xml"), cached_uri)], - ), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["target"], serde_json::json!("//a:cached")); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); - assert_eq!(outcomes[0]["cached"], serde_json::json!(true)); - assert_eq!(outcomes[0]["run"], serde_json::json!(2)); - assert_eq!(outcomes[0]["shard"], serde_json::json!(3)); - assert!(outcomes[0].get("configuration").is_none(), "{out}"); - assert!(outcomes[0].get("duration_millis").is_none(), "{out}"); - let finished_event = event(&events, "command_finished"); - assert_eq!( - finished_event["tests"], - serde_json::json!({"passed": 1, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - assert_eq!(finished_event["exit_code"], serde_json::json!(0)); - assert_eq!(finished_event["results_complete"], serde_json::json!(true)); - } - - #[test] - fn the_passed_count_matches_the_results_report() { - let harness = Harness::new("test-outcome-all-passed"); - let mut lines = Vec::new(); - for index in 0..3 { - let uri = write_bep_artifact( - &harness, - &format!("cases-{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ); - lines.push(test_result_line( - &format!("//rust:compile_test{index}"), - &[(String::from("test.xml"), uri)], - )); - } - for index in 0..4 { - let uri = write_bep_artifact( - &harness, - &format!("empty-{index}.xml"), - EMPTY_TEST_XML.as_bytes(), - ); - lines.push(test_result_line( - &format!("//go:generation_test{index}"), - &[(String::from("test.xml"), uri)], - )); - } - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert!(outcomes.is_empty(), "{out}"); - let results: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json"); - let entries = results["results"].as_array().expect("results"); - assert_eq!(entries.len(), 7, "{results}"); - assert!( - entries.iter().all(|entry| entry["bazel_status"] == "PASSED"), - "{results}" - ); - let no_cases = entries - .iter() - .filter(|entry| entry["outcome"] == "no_cases") - .count(); - assert_eq!(no_cases, 4, "{results}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 7, "failed": 0, "cached": 0, "no_cases": 4, "skipped": 0}) - ); - } - - #[test] - fn test_outcome_counts_cases_by_severity() { - let harness = Harness::new("test-outcome-cases"); - let uri = write_bep_artifact(&harness, "mixed.xml", MIXED_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - None, - None, - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); - assert_eq!(outcomes[0]["cases_total"], serde_json::json!(4)); - assert_eq!(outcomes[0]["cases_failed"], serde_json::json!(1)); - assert_eq!(outcomes[0]["cases_error"], serde_json::json!(1)); - assert_eq!(outcomes[0]["cases_skipped"], serde_json::json!(1)); - assert!(outcomes[0].get("artifacts").is_none(), "{out}"); - assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); - let failing = outcomes[0]["failing_cases"].as_array().expect("failing"); - let names: Vec<_> = failing.iter().map(|case| case["name"].clone()).collect(); - assert_eq!( - names, - vec![serde_json::json!("fails"), serde_json::json!("errors")] - ); - } - - #[test] - fn test_flaky_result_reports_passed_after_retry() { - let harness = Harness::new("test-outcome-flaky"); - let first_uri = write_bep_artifact(&harness, "first.xml", MINIMAL_TEST_XML.as_bytes()); - let retry_uri = write_bep_artifact(&harness, "retry.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - Some(false), - None, - &[(String::from("test.xml"), first_uri)], - ), - test_result_full_line( - "//a:t", - 1, - 1, - 2, - "FLAKY", - None, - Some(false), - None, - &[(String::from("test.xml"), retry_uri)], - ), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 2, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); - assert_eq!(outcomes[0]["attempt"], serde_json::json!(1)); - assert_eq!(outcomes[0]["final"], serde_json::json!(false), "{out}"); - assert_eq!( - outcomes[1]["outcome"], - serde_json::json!("passed_after_retry") - ); - assert_eq!(outcomes[1]["attempt"], serde_json::json!(2)); - assert!(outcomes[1].get("final").is_none(), "{out}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - let read_results = || -> serde_json::Value { - serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json") - }; - let results = read_results(); - let entries = results["results"].as_array().expect("results"); - assert_eq!(entries.len(), 2, "{results}"); - assert_eq!(entries[0]["attempt"], serde_json::json!(1)); - assert_eq!(entries[0]["final"], serde_json::json!(false), "{results}"); - assert_eq!(entries[1]["attempt"], serde_json::json!(2)); - assert!(entries[1].get("final").is_none(), "{results}"); - let (code, out, err) = harness.run(&["test", "--output=text", "--report=results=res.json"]); - assert_eq!(code, 0, "{out}{err}"); - let results = read_results(); - let entries = results["results"].as_array().expect("results"); - assert_eq!(entries.len(), 2, "{results}"); - assert_eq!(entries[0]["attempt"], serde_json::json!(1)); - assert_eq!(entries[0]["final"], serde_json::json!(false), "{results}"); - assert!(entries[1].get("final").is_none(), "{results}"); - } - - #[test] - fn test_flaky_summary_marks_retried_pass_distinctly() { - let harness = Harness::new("test-summary-flaky"); - let first_uri = write_bep_artifact(&harness, "first.xml", MINIMAL_TEST_XML.as_bytes()); - let retry_uri = write_bep_artifact(&harness, "retry.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - Some(false), - None, - &[(String::from("test.xml"), first_uri)], - ), - test_result_full_line( - "//a:t", - 1, - 1, - 2, - "PASSED", - None, - Some(false), - None, - &[(String::from("test.xml"), retry_uri)], - ), - test_flaky_summary_line("//a:t"), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 2, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); - assert_eq!(outcomes[0]["attempt"], serde_json::json!(1)); - assert_eq!( - outcomes[1]["outcome"], - serde_json::json!("passed_after_retry") - ); - assert_eq!(outcomes[1]["attempt"], serde_json::json!(2)); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - } - - #[test] - fn test_flaky_success_is_visible_in_text_summary() { - let harness = Harness::new("test-flaky-text"); - let first_uri = write_bep_artifact(&harness, "first.xml", MINIMAL_TEST_XML.as_bytes()); - let retry_uri = write_bep_artifact(&harness, "retry.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - Some(false), - None, - &[(String::from("test.xml"), first_uri)], - ), - test_result_full_line( - "//a:t", - 1, - 1, - 2, - "PASSED", - None, - Some(false), - None, - &[(String::from("test.xml"), retry_uri)], - ), - test_flaky_summary_line("//a:t"), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!( - err.contains("dx: flaky success (passed after retry): //a:t (attempt 2)"), - "{err}" - ); - } - - #[test] - fn test_clean_pass_prints_no_flaky_summary() { - let harness = Harness::new("test-clean-text"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("flaky success"), "{err}"); - } - - #[test] - fn flaky_summary_without_result_prints_no_attempt() { - let harness = Harness { - raw_bep: Some(vec![test_flaky_summary_line("//a:t")]), - ..Harness::new("test-flaky-summary-only") - }; - let (_, _, err) = harness.run(&["test", "--output=text"]); - assert!( - err.contains("dx: flaky success (passed after retry): //a:t\n"), - "{err}" - ); - assert!(!err.contains("(attempt"), "{err}"); - } - - #[test] - fn later_planned_report_event_is_incomplete_after_an_earlier_write_failure() { - let harness = Harness::new("test-writefail-later"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&[ - "test", - "--output=json", - "--report=results=missing-dir/r.json", - "--report=junit=j.xml", - ]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let reports = events_of_kind(&events, "report"); - assert_eq!(reports.len(), 1, "{out}"); - assert_eq!(reports[0]["format"], serde_json::json!("junit"), "{out}"); - assert_eq!( - reports[0]["results_complete"], - serde_json::json!(false), - "{out}" - ); - } - - fn evidence(problems: &[&str]) -> super::Evidence { - let mut evidence = super::Evidence::default(); - for problem in problems { - evidence.record((*problem).to_owned()); - } - evidence - } - - #[test] - fn evidence_gap_names_what_made_results_incomplete() { - assert_eq!( - super::evidence_gap(true, &evidence(&[]), "none reported"), - Some("none reported".to_owned()) - ); - assert_eq!( - super::evidence_gap(true, &evidence(&["a", "b"]), "none reported"), - Some("a".to_owned()) - ); - assert_eq!( - super::evidence_gap(false, &evidence(&["a", "b"]), "none reported"), - Some("2 missing or invalid results: a".to_owned()) - ); - assert_eq!( - super::evidence_gap(false, &evidence(&[]), "none reported"), - None - ); - } - - fn located(xml: &str) -> Vec<(Option, Option)> { - let cases = dx_plan::reports::parse_test_xml(xml.as_bytes(), 0, 0).expect("cases"); - cases - .iter() - .map(|case| { - let found = super::test_case(case); - (found.file, found.line) - }) - .collect() - } - - #[test] - fn a_case_takes_file_and_line_from_the_report_attributes() { - let xml = r#"trace"#; - assert_eq!(located(xml), [(Some("t/a_test.py".to_owned()), Some(7))]); - } - - #[test] - fn a_case_takes_file_and_line_from_the_failure_message() { - let xml = r#"tat Foo.method(Foo.java:17)"#; - assert_eq!( - located(xml), - [ - (Some("foo_test.go".to_owned()), Some(42)), - (Some("Foo.java".to_owned()), Some(17)), - ] - ); - } - - #[test] - fn a_case_without_a_location_leaves_file_and_line_unset() { - let xml = r#"t"#; - assert_eq!(located(xml), [(None, None), (None, None)]); - } - - #[test] - fn parse_cases_stamps_run_and_configuration() { - let result = dx_bep::TestResultRecord { - label: "//a:t".to_owned(), - configuration: Some("cfg".to_owned()), - run: 2, - shard: 3, - attempt: 1, - status: Some("PASSED".to_owned()), - cached_locally: None, - duration_millis: None, - strategy: None, - files: Vec::new(), - }; - let cases = super::parse_cases(&result, MINIMAL_TEST_XML.as_bytes()).expect("cases"); - assert!(!cases.is_empty()); - for case in &cases { - assert_eq!(case.run, 1); - assert_eq!(case.configuration.as_deref(), Some("cfg")); - } - assert!(super::parse_cases(&result, b"no elements").is_err()); - } - - #[test] - fn coverage_summary_prints_the_total_and_the_worst_files() { - let mut account = dx_lcov::CellAccount { - eligible_lines: 20, - covered_lines: 13, - ..dx_lcov::CellAccount::default() - }; - for (path, counts) in [("a.rs", (10, 10)), ("b.rs", (3, 6)), ("c.rs", (0, 4))] { - account.file_lines.insert(path.to_owned(), counts); - } - let text = dx_output::OutputMode::Text { quiet: false }; - let mut err = Vec::new(); - super::coverage_summary(&account, true, text, &mut err); - let got = String::from_utf8(err).unwrap(); - assert!(got.contains("dx: coverage 65.0% (13/20 lines)"), "{got}"); - assert!(!got.contains("a.rs"), "{got}"); - assert!( - got.find("c.rs").unwrap() < got.find("b.rs").unwrap(), - "{got}" - ); - let mut err = Vec::new(); - super::coverage_summary(&account, false, text, &mut err); - assert!(!String::from_utf8(err).unwrap().contains("dx: coverage 65")); - let mut err = Vec::new(); - super::coverage_summary(&account, true, dx_output::OutputMode::Json, &mut err); - assert!(err.is_empty()); - } - - #[test] - fn coverage_threshold_passes_without_minimum_and_fails_on_incomplete_evidence() { - let mut out = Vec::new(); - let mut err = Vec::new(); - let sources = super::Sources::default(); - let text = dx_output::OutputMode::Text { quiet: false }; - assert_eq!( - super::coverage_threshold(None, None, &sources, text, &mut out, &mut err), - Ok(true) - ); - assert_eq!( - super::coverage_threshold(Some(80), None, &sources, text, &mut out, &mut err), - Ok(false) - ); - assert!(out.is_empty() && err.is_empty()); - } - - #[test] - fn test_timeout_and_failure_outcomes_stay_distinct() { - let harness = Harness::new("test-outcome-statuses"); - let timeout_uri = write_bep_artifact(&harness, "timeout.xml", MINIMAL_TEST_XML.as_bytes()); - let failed_uri = write_bep_artifact(&harness, "failed.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_full_line( - "//a:slow", - 1, - 1, - 1, - "TIMEOUT", - None, - None, - None, - &[(String::from("test.xml"), timeout_uri)], - ), - test_result_full_line( - "//a:bad", - 1, - 1, - 1, - "FAILED", - None, - None, - None, - &[(String::from("test.xml"), failed_uri)], - ), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 2, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); - assert_eq!(outcomes[1]["outcome"], serde_json::json!("timeout")); - assert_eq!(outcomes[1]["bazel_status"], serde_json::json!("TIMEOUT")); - } - - #[test] - fn test_missing_status_reports_unknown_outcome() { - let harness = Harness::new("test-outcome-unknown"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let line = serde_json::json!({ - "id": {"testResult": {"label": "//a:t", "run": 1, "shard": 1, "attempt": 1}}, - "testResult": {"testActionOutput": [{"name": "test.xml", "uri": uri}]}, - }) - .to_string(); - let harness = Harness { - raw_bep: Some(vec![line]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("unknown")); - assert!(outcomes[0].get("bazel_status").is_none(), "{out}"); - assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); - } - - #[test] - fn test_zero_case_xml_reports_zero_counts() { - let harness = Harness::new("test-outcome-zero"); - let uri = write_bep_artifact(&harness, "empty.xml", EMPTY_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - assert!(events_of_kind(&events, "test_outcome").is_empty(), "{out}"); - let results: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json"); - let entry = &results["results"][0]; - assert_eq!(entry["outcome"], serde_json::json!("no_cases"), "{results}"); - assert_eq!(entry["cases_total"], serde_json::json!(0), "{results}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 1, "skipped": 0}) - ); - } - - #[test] - fn test_cached_no_cases_result_counts_cached_and_no_cases() { - let harness = Harness::new("test-outcome-cached-zero"); - let uri = write_bep_artifact( - &harness, - "empty.xml", - "".as_bytes(), - ); - let harness = Harness { - raw_bep: Some(vec![test_result_full_line( - "//a:t", - 1, - 1, - 1, - "PASSED", - None, - Some(true), - None, - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - assert!(events_of_kind(&events, "test_outcome").is_empty(), "{out}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 1, "no_cases": 1, "skipped": 0}) - ); - } - - #[test] - fn test_status_only_result_is_incomplete_evidence() { - let harness = Harness { - raw_bep: Some(vec![String::from( - "{\"id\": {\"testResult\": {\"label\": \"//a:t\"}}, \"testResult\": {\"status\": \"PASSED\"}}", - )]), - ..Harness::new("test-outcome-nofile") - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("passed")); - assert_eq!(outcomes[0]["evidence_complete"], serde_json::json!(false)); - assert_eq!(outcomes[0]["artifacts"]["missing"], serde_json::json!(1)); - let finished = event(&events, "command_finished"); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - } - - #[test] - fn test_single_missing_xml_fails_when_bazel_passed() { - let harness = Harness::new("test-partial-missing"); - let raw = (0..4) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("test.xml"), - write_bep_artifact( - &harness, - &format!("strict-ok-{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - lines.push(test_result_line( - "//a:missing", - &[(String::from("test.xml"), missing_uri("strict-missing.xml"))], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 4, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - let missing = outcomes - .iter() - .find(|outcome| outcome["target"] == serde_json::json!("//a:missing")) - .expect("missing target outcome"); - assert_eq!(missing["evidence_complete"], serde_json::json!(false)); - assert_eq!(missing["artifacts"]["missing"], serde_json::json!(1)); - let incomplete: Vec<_> = events_of_kind(&events, "error") - .into_iter() - .filter(|error| error["code"] == serde_json::json!("incomplete_results")) - .collect(); - assert_eq!(incomplete.len(), 1, "{out}"); - let finished = event(&events, "command_finished"); - assert_eq!(finished["exit_code"], serde_json::json!(1)); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - } - - #[test] - fn test_missing_xml_reports_text_failure() { - let harness = Harness::new("test-strict-text"); - let raw = (0..4) - .map(|index| { - test_result_line( - &format!("//a:ok{index}"), - &[( - String::from("test.xml"), - write_bep_artifact( - &harness, - &format!("strict-text-ok-{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ), - )], - ) - }) - .collect::>(); - let mut lines = raw; - lines.push(test_result_line( - "//a:missing", - &[( - String::from("test.xml"), - missing_uri("strict-text-missing.xml"), - )], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn test_complete_run_passes() { - let harness = Harness::new("test-strict-complete"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("incomplete_results"), "{err}"); - let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); - let text = String::from_utf8(document).expect("utf8"); - assert!(text.contains(" = events_of_kind(&events, "error") - .into_iter() - .filter(|error| error["code"] == serde_json::json!("bazel_failed")) - .collect(); - assert_eq!(failed.len(), 1, "{out}"); - assert_eq!(failed[0]["cause"], serde_json::json!("bazel"), "{out}"); - assert_eq!(failed[0]["status"], serde_json::json!(4), "{out}"); - assert_eq!(failed[0]["phase"], serde_json::json!("execute"), "{out}"); - let finished = event(&events, "command_finished"); - assert_eq!(finished["exit_code"], serde_json::json!(4)); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - } - - #[test] - fn test_label_pattern_without_tests_reports_no_tests() { - let harness = Harness { - bazel_code: 4, - ..Harness::new("test-no-tests-pattern") - }; - let (code, out, err) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 4, "{out}{err}"); - let events = json_events(&out); - assert!( - errors_with_code(&events, "bazel_failed").is_empty(), - "{out}" - ); - let found = errors_with_code(&events, "no_tests"); - assert_eq!(found.len(), 1, "{out}"); - assert_eq!(found[0]["status"], serde_json::json!(4), "{out}"); - assert!( - err.contains("next: check the target or path scope and run the command again."), - "{err}" - ); - assert_eq!( - event(&events, "command_finished")["exit_code"], - serde_json::json!(4) - ); - } - - fn failing_run(name: &str, statuses: &[&str]) -> (i32, String, String) { - let harness = Harness::new(name); - let lines = statuses - .iter() - .enumerate() - .map(|(index, status)| { - let uri = write_bep_artifact( - &harness, - &format!("{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ); - test_result_full_line( - &format!("//a:t{index}"), - 1, - 1, - 1, - status, - None, - None, - None, - &[(String::from("test.xml"), uri)], - ) - }) - .collect(); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(lines), - ..harness - }; - harness.run(&["test", "--output=json"]) - } - - fn errors_with_code<'a>( - events: &'a [serde_json::Value], - code: &str, - ) -> Vec<&'a serde_json::Value> { - events_of_kind(events, "error") - .into_iter() - .filter(|error| error["code"] == serde_json::json!(code)) - .collect() - } - - #[test] - fn failing_tests_emit_tests_failed_with_counts() { - let (code, out, err) = failing_run("test-tests-failed", &["PASSED", "FAILED"]); - assert_eq!(code, 3, "{out}{err}"); - let events = json_events(&out); - let errors = events_of_kind(&events, "error"); - assert_eq!(errors.len(), 1, "{out}"); - assert_eq!( - errors[0]["code"], - serde_json::json!("tests_failed"), - "{out}" - ); - assert_eq!(errors[0]["cause"], serde_json::json!("test"), "{out}"); - assert_eq!(errors[0]["status"], serde_json::json!(3), "{out}"); - assert_eq!(errors[0]["phase"], serde_json::json!("execute"), "{out}"); - assert_eq!( - errors[0]["message"], - serde_json::json!("1 test failed; read the test_outcome events"), - "{out}" - ); - assert!( - errors_with_code(&events, "bazel_failed").is_empty(), - "{out}" - ); - assert!(!out.contains("dx status"), "{out}"); - assert!(err.is_empty(), "{err}"); - let finished = event(&events, "command_finished"); - assert_eq!( - finished["tests"], - serde_json::json!({"passed": 1, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - assert_eq!(finished["exit_code"], serde_json::json!(3)); - } - - fn attempts_run( - name: &str, - attempts: &[(&str, u32, &str)], - flaky: &[&str], - ) -> (i32, String, String) { - let harness = Harness::new(name); - let mut lines: Vec = attempts - .iter() - .enumerate() - .map(|(index, (label, attempt, status))| { - let uri = write_bep_artifact( - &harness, - &format!("{index}.xml"), - MINIMAL_TEST_XML.as_bytes(), - ); - test_result_full_line( - label, - 1, - 1, - *attempt, - status, - None, - Some(false), - None, - &[(String::from("test.xml"), uri)], - ) - }) - .collect(); - lines.extend(flaky.iter().map(|label| test_flaky_summary_line(label))); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(lines), - ..harness - }; - harness.run(&["test", "--output=json"]) - } - - #[test] - fn flaky_target_with_real_failure_counts_one_failure() { - let (code, out, err) = attempts_run( - "test-flaky-and-failed", - &[ - ("//a:t", 1, "FAILED"), - ("//a:t", 2, "PASSED"), - ("//b:t", 1, "FAILED"), - ], - &["//a:t"], - ); - assert_eq!(code, 3, "{out}{err}"); - let events = json_events(&out); - let errors = errors_with_code(&events, "tests_failed"); - assert_eq!(errors.len(), 1, "{out}"); - assert_eq!( - errors[0]["message"], - serde_json::json!("1 test failed; read the test_outcome events"), - "{out}" - ); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 3, "{out}"); - let finals: Vec<(String, u64, Option)> = outcomes - .iter() - .map(|outcome| { - ( - outcome["target"].as_str().expect("target").to_owned(), - outcome["attempt"].as_u64().expect("attempt"), - outcome.get("final").cloned(), - ) - }) - .collect(); - assert!( - finals.contains(&("//a:t".to_owned(), 1, Some(serde_json::json!(false)))), - "{out}" - ); - assert!(finals.contains(&("//a:t".to_owned(), 2, None)), "{out}"); - assert!(finals.contains(&("//b:t".to_owned(), 1, None)), "{out}"); - assert_eq!(final_failures(&outcomes), 1, "{out}"); - assert!(err.is_empty(), "{err}"); - } - - fn final_failures(outcomes: &[&serde_json::Value]) -> usize { - outcomes - .iter() - .filter(|outcome| outcome["outcome"] == "failed" && outcome.get("final").is_none()) - .count() - } - - #[test] - fn retried_failure_counts_once() { - let (code, out, err) = attempts_run( - "test-retried-failure", - &[ - ("//a:t", 1, "FAILED"), - ("//a:t", 2, "FAILED"), - ("//a:t", 3, "FAILED"), - ], - &[], - ); - assert_eq!(code, 3, "{out}{err}"); - let events = json_events(&out); - let errors = errors_with_code(&events, "tests_failed"); - assert_eq!(errors.len(), 1, "{out}"); - assert_eq!( - errors[0]["message"], - serde_json::json!("1 test failed; read the test_outcome events"), - "{out}" - ); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 0, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 3, "{out}"); - for outcome in &outcomes { - let expected = if outcome["attempt"] == 3 { - None - } else { - Some(&serde_json::json!(false)) - }; - assert_eq!(outcome.get("final"), expected, "{out}"); - } - assert_eq!(final_failures(&outcomes), 1, "{out}"); - } - - #[test] - fn every_failed_test_still_reports_counts() { - let (code, out, _) = failing_run("test-all-failed", &["FAILED", "TIMEOUT"]); - assert_eq!(code, 3, "{out}"); - let events = json_events(&out); - let failed = errors_with_code(&events, "tests_failed"); - assert_eq!(failed.len(), 1, "{out}"); - assert_eq!( - failed[0]["message"], - serde_json::json!("2 tests failed; read the test_outcome events"), - "{out}" - ); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 0, "failed": 2, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - } - - #[test] - fn exit_three_without_failed_results_keeps_bazel_failed() { - let (code, out, _) = failing_run("test-exit-three-passed", &["PASSED"]); - assert_eq!(code, 3, "{out}"); - let events = json_events(&out); - assert!( - errors_with_code(&events, "tests_failed").is_empty(), - "{out}" - ); - let failed = errors_with_code(&events, "bazel_failed"); - assert_eq!(failed.len(), 1, "{out}"); - assert_eq!(failed[0]["cause"], serde_json::json!("bazel"), "{out}"); - assert_eq!(failed[0]["status"], serde_json::json!(3), "{out}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - } - - #[test] - fn test_server_crash_reports_server_crashed() { - let harness = Harness::new("test-server-crash"); - let uri = write_bep_artifact(&harness, "0.xml", MINIMAL_TEST_XML.as_bytes()); - let line = test_result_full_line( - "//a:t0", - 1, - 1, - 1, - "PASSED", - None, - None, - None, - &[(String::from("test.xml"), uri)], - ); - let harness = Harness { - bazel_code: 37, - raw_bep: Some(vec![line]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json", "//a:t0"]); - assert_eq!(code, 37, "{out}"); - let events = json_events(&out); - let crashed = errors_with_code(&events, "server_crashed"); - assert_eq!(crashed.len(), 1, "{out}"); - assert_eq!(crashed[0]["cause"], serde_json::json!("host"), "{out}"); - assert!( - errors_with_code(&events, "bazel_failed").is_empty(), - "{out}" - ); - } - - #[test] - fn test_signalled_run_emits_no_test_outcome() { - let mut harness = Harness::new("test-outcome-signalled"); - harness.signalled = true; - let (code, out, err) = harness.run(&["test", "--output=json"]); - assert_ne!(code, 0, "{out}{err}"); - assert!(!out.contains("test_outcome"), "{out}"); - } - - #[test] - fn test_coverage_outcomes_mark_only_final_attempt_evidence() { - let harness = Harness::new("cov-outcome-retry"); - let final_uri = write_bep_artifact(&harness, "final.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_identity_line( - "//a:t", - 1, - 1, - 1, - &[(String::from("test.lcov"), missing_uri("cov-attempt1.dat"))], - ), - test_result_identity_line( - "//a:t", - 1, - 1, - 2, - &[(String::from("test.lcov"), final_uri)], - ), - ]), - ..harness - }; - let (code, out, _) = - harness.run(&["coverage", "--output=json", "--report=results=res.json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - assert!(events_of_kind(&events, "test_outcome").is_empty(), "{out}"); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - let results: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json"); - let entries = results["results"].as_array().expect("results"); - assert_eq!(entries.len(), 1, "{results}"); - assert_eq!(entries[0]["attempt"], serde_json::json!(2)); - assert!(entries[0].get("artifacts").is_none(), "{results}"); - assert!(entries[0].get("final").is_none(), "{results}"); - } - - const MIXED_CASES_XML: &str = r#"tracepanic text"#; - - fn completed_line(label: &str, children: &[serde_json::Value]) -> String { - serde_json::json!({ - "id": {"targetCompleted": {"label": label}}, - "completed": {"success": true}, - "children": children, - }) - .to_string() - } - - fn summary_line(label: &str, status: &str) -> String { - serde_json::json!({ - "id": {"testSummary": {"label": label}}, - "testSummary": {"overallStatus": status}, - }) - .to_string() - } - - #[test] - fn a_result_without_xml_beside_one_with_xml_is_incomplete() { - let harness = Harness::new("test-mixed-xml"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - test_result_line("//a:ok", &[(String::from("test.xml"), uri)]), - test_result_line("//b:bare", &[]), - ]), - ..harness - }; - let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains( - "dx: incomplete_results: test.xml for //b:bare (run 1, shard 1, attempt 1) was not reported" - ), - "{err}" - ); - let document = std::fs::read_to_string(harness.workspace.join("out.xml")).expect("junit"); - assert!( - document.contains(" = events_of_kind(&events, "error") - .into_iter() - .filter(|error| error["code"] == "incomplete_results") - .collect(); - assert_eq!(incomplete.len(), 1, "{out}"); - } - - #[test] - fn readable_reports_emit_each_case_with_its_identity() { - let harness = Harness::new("test-cases"); - let uri = write_bep_artifact(&harness, "cases.xml", MIXED_CASES_XML.as_bytes()); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", - 1, - 2, - 1, - "FAILED", - Some("cfg1"), - Some(true), - Some(40), - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); - assert_eq!(code, 3, "{out}"); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - assert_eq!(outcome["target"], serde_json::json!("//a:t")); - assert_eq!(outcome["configuration"], serde_json::json!("cfg1")); - assert_eq!(outcome["shard"], serde_json::json!(2)); - assert_eq!(outcome["cached"], serde_json::json!(true)); - assert_eq!(outcome["cases_total"], serde_json::json!(4)); - assert_eq!(outcome["cases_failed"], serde_json::json!(1)); - assert_eq!(outcome["cases_error"], serde_json::json!(1)); - assert_eq!(outcome["cases_skipped"], serde_json::json!(1)); - assert_eq!( - outcome["failing_cases"], - serde_json::json!([ - {"classname": "c", "name": "fails", "outcome": "failed", "message": "expected 1"}, - {"classname": "c", "name": "errs", "outcome": "error", "message": "panic text"}, - ]) - ); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json"); - let entries = document["results"].as_array().expect("results"); - assert_eq!(entries.len(), 1, "{document}"); - let named: Vec<(&str, &str)> = entries[0]["cases"] - .as_array() - .expect("cases") - .iter() - .map(|case| { - ( - case["name"].as_str().expect("name"), - case["outcome"].as_str().expect("outcome"), - ) - }) - .collect(); - assert_eq!( - named, - vec![ - ("passes", "passed"), - ("fails", "failed"), - ("errs", "error"), - ("ignored", "skipped"), - ] - ); - let reports = events_of_kind(&events, "report"); - assert_eq!(reports[0]["format"], serde_json::json!("results")); - assert_eq!(reports[0]["path"], serde_json::json!("res.json")); - } - - fn failing_result_files(harness: &Harness, with_xml: bool) -> Vec<(String, String)> { - let mut files = vec![( - String::from("test.lcov"), - write_bep_artifact(harness, "coverage.dat", MINIMAL_LCOV.as_bytes()), - )]; - if with_xml { - files.push(( - String::from("test.xml"), - write_bep_artifact(harness, "cases.xml", MIXED_CASES_XML.as_bytes()), - )); - } - files.push(( - String::from("test.log"), - write_bep_artifact(harness, "test.log", b"first\nboom happened\n"), - )); - files.push(( - String::from("test.outputs__outputs.zip"), - write_bep_artifact(harness, "outputs.zip", b"zip"), - )); - files - } - - #[test] - fn test_failing_outcome_carries_log_path_and_tail() { - let harness = Harness::new("test-failing-log"); - let files = failing_result_files(&harness, true); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, - )]), - ..harness - }; - let (_, out, _) = harness.run(&["test", "--output=json"]); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - assert!(outcome["log_path"].as_str().is_some(), "{out}"); - assert!( - outcome["outputs_path"] - .as_str() - .expect("outputs") - .ends_with("outputs.zip"), - "{out}" - ); - assert!( - outcome["log_tail"] - .as_str() - .expect("tail") - .contains("boom happened"), - "{out}" - ); - } - - #[test] - fn test_text_mode_names_failing_cases_and_log() { - let harness = Harness::new("test-failing-text"); - let files = failing_result_files(&harness, true); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, - )]), - ..harness - }; - let (_, _, err) = harness.run(&["test", "--output=text"]); - let line = err - .lines() - .find(|line| line.starts_with("dx: failed: //a:t: fails, errs")) - .unwrap_or_else(|| panic!("{err}")); - assert!( - line.contains("(log: ") && line.contains("test.log)"), - "{line}" - ); - } - - #[test] - fn coverage_failing_outcome_carries_failing_cases_and_log() { - let harness = Harness::new("cov-failing-cases"); - let files = failing_result_files(&harness, true); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, - )]), - ..harness - }; - let (_, out, _) = harness.run(&["coverage", "--output=json"]); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - let failing = outcome["failing_cases"].as_array().expect("failing"); - let names: Vec<&str> = failing - .iter() - .map(|case| case["name"].as_str().expect("name")) - .collect(); - assert_eq!(names, vec!["fails", "errs"], "{out}"); - assert!(outcome["log_path"].as_str().is_some(), "{out}"); - assert!( - outcome["log_tail"] - .as_str() - .expect("tail") - .contains("boom happened"), - "{out}" - ); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(true), - "{out}" - ); - } - - #[test] - fn coverage_failing_outcome_without_test_xml_stays_complete() { - let harness = Harness::new("cov-failing-no-xml"); - let files = failing_result_files(&harness, false); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, - )]), - ..harness - }; - let (_, out, _) = harness.run(&["coverage", "--output=json"]); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - assert!(outcome.get("failing_cases").is_none(), "{out}"); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(true), - "{out}" - ); - } - - #[test] - fn passed_count_is_per_run_and_shard() { - let harness = Harness::new("test-count-unit"); - let mut lines = Vec::new(); - let xml = vec![( - String::from("test.xml"), - write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()), - )]; - for run in 1..=2 { - for shard in 1..=2 { - let attempt_two = run == 1 && shard == 1; - if attempt_two { - lines.push(test_result_full_line( - "//a:t", run, shard, 1, "FAILED", None, None, None, &xml, - )); - lines.push(test_result_full_line( - "//a:t", run, shard, 2, "PASSED", None, None, None, &xml, - )); - } else { - lines.push(test_result_identity_line("//a:t", run, shard, 1, &xml)); - } - } - } - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (_, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); - let events = json_events(&out); - let tests = &event(&events, "command_finished")["tests"]; - assert_eq!(tests["passed"], serde_json::json!(4), "{out}"); - assert_eq!(tests["failed"], serde_json::json!(0), "{out}"); - let results: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json"); - let passed = results["results"] - .as_array() - .expect("results") - .iter() - .filter(|entry| entry["bazel_status"] == "PASSED" && entry["final"] != false) - .count(); - assert_eq!(passed, 4, "{results}"); - } - - #[test] - fn failing_case_past_the_case_limit_is_still_reported() { - let harness = Harness::new("test-late-failure"); - let mut xml = String::from(""); - for index in 0..1500 { - xml.push_str(&format!("")); - } - xml.push_str(""); - xml.push_str(""); - let uri = write_bep_artifact(&harness, "late.xml", xml.as_bytes()); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - None, - None, - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (_, out, _) = harness.run(&["test", "--output=json"]); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - let failing = outcome["failing_cases"].as_array().expect("failing"); - assert_eq!(failing.len(), 1, "{out}"); - assert_eq!(failing[0]["name"], serde_json::json!("late")); - } - - #[test] - fn default_results_report_failure_fails_the_run() { - let harness = Harness::new("test-results-default-blocked"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - std::fs::create_dir_all(harness.workspace.join(".dx/local/results.json")).expect("block"); - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - assert!( - events.iter().any(|event| event["code"] == "report_failed"), - "{out}" - ); - let finished = events.last().expect("command_finished"); - assert_eq!(finished["event"], "command_finished", "{out}"); - assert_eq!(finished["results_complete"], false, "{out}"); - } - - #[test] - fn default_results_report_failure_keeps_the_bazel_code() { - let harness = Harness::new("test-results-default-blocked-bazel"); - let uri = write_bep_artifact(&harness, "bad.xml", MIXED_TEST_XML.as_bytes()); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - None, - None, - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - std::fs::create_dir_all(harness.workspace.join(".dx/local/results.json")).expect("block"); - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 3, "{out}"); - let events = json_events(&out); - let finished = events.last().expect("command_finished"); - assert_eq!(finished["results_complete"], false, "{out}"); - } - - #[test] - fn results_document_run_id_matches_command_finished() { - let harness = Harness::new("test-results-run-id"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let finished = events.last().expect("command_finished"); - let run_id = finished["run_id"].as_str().expect("run_id in event"); - let file = std::fs::read_to_string(harness.workspace.join(".dx/local/results.json")) - .expect("results file"); - let document: serde_json::Value = serde_json::from_str(&file).expect("json"); - assert_eq!(document["run_id"], run_id); - assert_eq!(document["record_version"], 1); - } - - #[test] - fn explicit_results_report_carries_the_run_id() { - let harness = Harness::new("test-results-run-id-explicit"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, out, _) = harness.run(&[ - "test", - "--output=json", - "--report", - "results=elsewhere.json", - ]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let finished = events.last().expect("command_finished"); - let run_id = finished["run_id"].as_str().expect("run_id in event"); - let file = std::fs::read_to_string(harness.workspace.join("elsewhere.json")) - .expect("results file"); - let document: serde_json::Value = serde_json::from_str(&file).expect("json"); - assert_eq!(document["run_id"], run_id); - assert!(!harness.workspace.join(".dx/local/results.json").exists()); - } - - #[test] - fn case_detail_is_bounded_and_says_so() { - let harness = Harness::new("test-case-bounds"); - let long = "é".repeat(3000); - let mut xml = String::from(""); - xml.push_str(&format!( - "" - )); - for index in 0..1000 { - xml.push_str(&format!("")); - } - xml.push_str(""); - let uri = write_bep_artifact(&harness, "many.xml", xml.as_bytes()); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - None, - None, - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (_, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - assert_eq!(outcome["cases_total"], serde_json::json!(1001)); - let failing = outcome["failing_cases"].as_array().expect("failing"); - assert_eq!(failing.len(), 1); - let message = failing[0]["message"].as_str().expect("message"); - assert_eq!(message.len(), dx_output::EVENT_MESSAGE_LIMIT); - assert!(message.chars().all(|c| c == 'é')); - assert!(outcome.get("failing_cases_omitted").is_none(), "{out}"); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("res.json")).expect("results report"), - ) - .expect("results json"); - let cases = document["results"][0]["cases"].as_array().expect("cases"); - assert_eq!(cases.len(), 1000); - assert_eq!( - document["results"][0]["cases_omitted"], - serde_json::json!(1) - ); - assert_eq!(cases[0]["message"].as_str().expect("message").len(), 4096); - } - - #[test] - fn an_announced_result_that_never_arrives_is_incomplete() { - let harness = Harness::new("test-gap"); - let uri = write_bep_artifact(&harness, "shard1.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - completed_line( - "//a:t", - &[ - serde_json::json!({"testResult": {"label": "//a:t", "run": 1, "shard": 1, "attempt": 1}}), - serde_json::json!({"testResult": {"label": "//a:t", "run": 1, "shard": 2, "attempt": 1}}), - ], - ), - test_result_line("//a:t", &[(String::from("test.xml"), uri)]), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("not_reported")); - assert_eq!(outcomes[0]["shard"], serde_json::json!(2)); - assert_eq!(outcomes[0]["evidence_complete"], serde_json::json!(false)); - assert!(outcomes[0].get("bazel_status").is_none(), "{out}"); - assert!(outcomes[0].get("cases_total").is_none(), "{out}"); - let error = events_of_kind(&events, "error") - .into_iter() - .find(|error| error["code"] == "incomplete_results") - .expect("incomplete"); - assert_eq!( - error["message"], - serde_json::json!( - "testResult for //a:t (run 1, shard 2, attempt 1) was announced but never reported" - ) - ); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(false) - ); - } - - #[test] - fn a_cancelled_result_keeps_the_bazel_code_and_its_cause() { - let harness = - Harness { - bazel_code: 8, - raw_bep: Some(vec![serde_json::json!({ - "id": {"testResult": {"label": "//a:t", "run": 1, "shard": 1, "attempt": 1}}, - "aborted": {"reason": "USER_INTERRUPTED"}, - }) - .to_string()]), - ..Harness::new("test-cancelled") - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 8, "{out}"); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - assert_eq!(outcome["outcome"], serde_json::json!("cancelled")); - assert_eq!(outcome["evidence_complete"], serde_json::json!(false)); - assert_eq!( - event(&events, "command_finished")["exit_code"], - serde_json::json!(8) - ); - } - - #[test] - fn a_stream_without_bazels_last_event_is_incomplete() { - let harness = Harness::new("test-unterminated"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - unterminated_bep: true, - raw_bep: Some(vec![test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )]), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("dx: incomplete_results: build events ended before Bazel's last event"), - "{err}" - ); - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - assert!( - events_of_kind(&events, "test_outcome").is_empty(), - "the collected result itself stays complete and is counted: {out}" - ); - assert_eq!( - event(&events, "command_finished")["tests"], - serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) - ); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(false) - ); - } - - #[test] - fn a_summary_without_results_is_its_own_outcome_without_cases() { - let harness = Harness { - bazel_code: 1, - raw_bep: Some(vec![summary_line("//a:broken", "FAILED_TO_BUILD")]), - ..Harness::new("test-summary-only") - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let outcome = event(&events, "test_outcome"); - assert_eq!(outcome["target"], serde_json::json!("//a:broken")); - assert_eq!(outcome["outcome"], serde_json::json!("failed_to_build")); - assert_eq!(outcome["bazel_status"], serde_json::json!("FAILED_TO_BUILD")); - assert_eq!(outcome["evidence_complete"], serde_json::json!(false)); - for field in ["cases_total", "run", "shard", "attempt", "cached"] { - assert!(outcome.get(field).is_none(), "{field}: {out}"); - } - - let harness = Harness { - raw_bep: Some(vec![summary_line("//a:t", "PASSED")]), - ..Harness::new("test-summary-only-pass") - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 1, "a summary is not a collected result: {err}"); - assert!( - err.contains("no test result was reported for //a:t (summary PASSED)"), - "{err}" - ); - } - - #[test] - fn a_test_that_fails_to_build_is_its_own_outcome() { - let configured = |label: &str| { - serde_json::json!({ - "id": {"targetConfigured": {"label": label}}, - "configured": {"targetKind": "sh_test rule", "testSize": "MEDIUM"}, - }) - .to_string() - }; - let completed = |label: &str, success: bool| { - serde_json::json!({ - "id": {"targetCompleted": {"label": label, "configuration": {"id": "cfg1"}}}, - "completed": {"success": success}, - }) - .to_string() - }; - let harness = Harness::new("test-unbuilt"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - bazel_code: 1, - raw_bep: Some(vec![ - configured("//a:broken"), - configured("//a:t"), - serde_json::json!({ - "id": {"targetCompleted": {"label": "//a:broken", "configuration": {"id": "cfg1"}}}, - "completed": {"outputGroup": [{"name": "default", "fileSets": []}]}, - }) - .to_string(), - completed("//a:t", true), - test_result_line("//a:t", &[(String::from("test.xml"), uri)]), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["target"], serde_json::json!("//a:broken")); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed_to_build")); - assert_eq!(outcomes[0]["configuration"], serde_json::json!("cfg1")); - assert_eq!(outcomes[0]["evidence_complete"], serde_json::json!(false)); - for field in ["bazel_status", "cases_total", "run", "shard", "attempt", "cached"] { - assert!(outcomes[0].get(field).is_none(), "{field}: {out}"); - } - let error = events_of_kind(&events, "error") - .into_iter() - .find(|error| error["code"] == "incomplete_results") - .expect("incomplete"); - assert_eq!(error["code"], serde_json::json!("incomplete_results")); - assert_eq!( - error["message"], - serde_json::json!("//a:broken failed to build") - ); - let finished = event(&events, "command_finished"); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - - let harness = Harness { - bazel_code: 1, - raw_bep: Some(vec![ - configured("//a:broken"), - completed("//a:broken", false), - summary_line("//a:broken", "FAILED_TO_BUILD"), - ]), - ..Harness::new("test-unbuilt-summarized") - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["bazel_status"], serde_json::json!("FAILED_TO_BUILD")); - } - - #[test] - fn skipped_targets_are_reported_without_blocking_collected_results() { - let harness = Harness::new("test-skipped"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let configured = serde_json::json!({ - "id": {"targetConfigured": {"label": "//a:windows_only"}}, - "configured": {"targetKind": "sh_test rule", "testSize": "SMALL"}, - }) - .to_string(); - let skipped = serde_json::json!({ - "id": {"targetCompleted": {"label": "//a:windows_only", "configuration": {"id": "cfg1"}}}, - "aborted": {"reason": "SKIPPED"}, - }) - .to_string(); - let harness = Harness { - raw_bep: Some(vec![ - configured.clone(), - skipped.clone(), - test_result_line("//a:t", &[(String::from("test.xml"), uri)]), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["target"], serde_json::json!("//a:windows_only")); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("skipped")); - assert_eq!(outcomes[0]["configuration"], serde_json::json!("cfg1")); - assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(true) - ); - - let only_skipped = Harness { - raw_bep: Some(vec![configured, skipped]), - ..Harness::new("test-only-skipped") - }; - let (code, out, _) = only_skipped.run(&["test", "--output=json"]); - assert_eq!(code, 0, "nothing was selected to run: {out}"); - let events = json_events(&out); - assert_eq!( - event(&events, "test_outcome")["outcome"], - serde_json::json!("skipped") - ); - } - - fn filtered_target_lines() -> Vec { - vec![ - serde_json::json!({ - "id": {"targetConfigured": {"label": "//a:slow"}}, - "configured": {"targetKind": "sh_test rule", "testSize": "LARGE"}, - }) - .to_string(), - serde_json::json!({ - "id": {"targetCompleted": {"label": "//a:slow", "configuration": {"id": "cfg1"}}}, - "completed": {"success": true}, - }) - .to_string(), - ] - } - - #[test] - fn filtered_targets_are_reported_and_counted() { - let harness = Harness::new("test-filtered"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let mut lines = filtered_target_lines(); - lines.push(test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, out, _) = harness.run(&["test", "--output=json"]); - assert_eq!(code, 0, "{out}"); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert_eq!(outcomes.len(), 1, "{out}"); - assert_eq!(outcomes[0]["target"], serde_json::json!("//a:slow")); - assert_eq!(outcomes[0]["outcome"], serde_json::json!("filtered")); - assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); - let notices: Vec<_> = events_of_kind(&events, "notice") - .into_iter() - .filter(|notice| notice["code"] == serde_json::json!("tests_filtered")) - .collect(); - assert_eq!(notices.len(), 1, "{out}"); - let finished = event(&events, "command_finished"); - assert_eq!(finished["tests"]["skipped"], serde_json::json!(1), "{out}"); - assert_eq!(finished["tests"]["failed"], serde_json::json!(0), "{out}"); - } - - #[test] - fn skipped_targets_print_a_summary_line_and_keep_the_exit_code() { - let harness = Harness::new("test-skipped-notice"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let lines = vec![ - serde_json::json!({ - "id": {"targetConfigured": {"label": "//a:skip"}}, - "configured": {"targetKind": "sh_test rule", "testSize": "SMALL"}, - }) - .to_string(), - serde_json::json!({ - "id": {"targetCompleted": {"label": "//a:skip", "configuration": {"id": "cfg1"}}}, - "aborted": {"reason": "SKIPPED"}, - }) - .to_string(), - test_result_line("//a:t", &[(String::from("test.xml"), uri)]), - ]; - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert_eq!(err.matches("dx: skipped 1 target ").count(), 1, "{err}"); - } - - #[test] - fn filtered_targets_print_a_stderr_line_in_text_mode() { - let harness = Harness::new("test-filtered-text"); - let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); - let mut lines = filtered_target_lines(); - lines.push(test_result_line( - "//a:t", - &[(String::from("test.xml"), uri)], - )); - let harness = Harness { - raw_bep: Some(lines), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert_eq!(err.matches("dx: tests_filtered: ").count(), 1, "{err}"); - assert!(err.contains("//a:slow"), "{err}"); - } - - #[test] - fn coverage_reports_filtered_targets() { - let mut harness = coverage_harness("cov-filtered", MINIMAL_LCOV.as_bytes()); - let mut lines = filtered_target_lines(); - lines.extend(harness.raw_bep.take().expect("bep")); - harness.raw_bep = Some(lines); - let (_, out, _) = harness.run(&["coverage", "--output=json"]); - let events = json_events(&out); - let outcomes = events_of_kind(&events, "test_outcome"); - assert!( - outcomes - .iter() - .any(|outcome| outcome["outcome"] == serde_json::json!("filtered")), - "{out}" - ); - } - - #[test] - fn coverage_without_bazels_last_event_is_incomplete() { - let mut harness = coverage_harness("cov-unterminated", MINIMAL_LCOV.as_bytes()); - harness.unterminated_bep = true; - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("dx: incomplete_results: build events ended before Bazel's last event"), - "{err}" - ); - } - - fn no_collector_configured(label: &str) -> String { - serde_json::json!({ - "id": {"targetConfigured": {"label": label}}, - "configured": { - "targetKind": "_zig_forward_test rule", - "testSize": "SMALL", - "tag": ["manual", "dx-no-coverage-collector"], - }, - }) - .to_string() - } - - #[test] - fn coverage_fails_when_only_collectorless_tests_ran() { - let harness = Harness::new("cov-no-collector-only"); - let empty = write_bep_artifact(&harness, "zig.dat", b""); - let harness = Harness { - raw_bep: Some(vec![ - no_collector_configured("//z:t"), - test_result_line("//z:t", &[(String::from("coverage.dat"), empty)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--fail-on=warning"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("dx: no_coverage_collector: no coverage collector for //z:t"), - "{err}" - ); - assert!(!err.contains("incomplete_results"), "{err}"); - } - - #[test] - fn coverage_fails_when_collectorless_tests_mix_with_lcov() { - let harness = Harness::new("cov-no-collector-mixed"); - let empty = write_bep_artifact(&harness, "zig2.dat", b""); - let ok = write_bep_artifact(&harness, "rust.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - no_collector_configured("//z:t"), - test_result_line("//z:t", &[(String::from("coverage.dat"), empty.clone())]), - test_result_line("//z:t", &[(String::from("coverage.dat"), empty)]), - test_result_line("//r:t", &[(String::from("coverage.dat"), ok)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - "--fail-on=warning", - "--report=lcov=out.lcov", - ]); - assert_eq!(code, 1, "{err}"); - assert_eq!(err.matches("no_coverage_collector").count(), 1, "{err}"); - let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); - assert_eq!(document, MINIMAL_LCOV.as_bytes()); - } - - #[test] - fn coverage_skips_collectorless_tests_with_one_line_per_language() { - let harness = Harness::new("cov-no-collector-skip"); - let empty = write_bep_artifact(&harness, "zig4.dat", b""); - let ok = write_bep_artifact(&harness, "rust2.dat", MINIMAL_LCOV.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - no_collector_configured("//z:a"), - no_collector_configured("//z:b"), - test_result_line("//z:a", &[(String::from("coverage.dat"), empty.clone())]), - test_result_line("//z:b", &[(String::from("coverage.dat"), empty)]), - test_result_line("//r:t", &[(String::from("coverage.dat"), ok)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["coverage", "--output=text", "--fail-on=error"]); - assert_eq!(code, 0, "{err}"); - assert_eq!( - err.matches("dx: skipped 2 zig tests: no coverage collector (#7298)") - .count(), - 1, - "{err}" - ); - assert!(!err.contains("dx: no_coverage_collector"), "{err}"); - } - - #[test] - fn coverage_ignores_collectorless_tests_without_results() { - let mut harness = coverage_harness("cov-no-collector-filtered", MINIMAL_LCOV.as_bytes()); - let mut lines = vec![no_collector_configured("//z:skipped")]; - lines.extend(harness.raw_bep.take().expect("bep")); - harness.raw_bep = Some(lines); - let (code, _, err) = harness.run(&["coverage", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("no_coverage_collector"), "{err}"); - } - - #[test] - fn coverage_no_collector_is_a_json_error_event() { - let harness = Harness::new("cov-no-collector-json"); - let empty = write_bep_artifact(&harness, "zig3.dat", b""); - let harness = Harness { - raw_bep: Some(vec![ - no_collector_configured("//z:t"), - test_result_line("//z:t", &[(String::from("coverage.dat"), empty)]), - ]), - ..harness - }; - let (code, out, _) = harness.run(&["coverage", "--output=json", "--fail-on=warning"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - assert!( - events_of_kind(&events, "error") - .iter() - .any(|event| event["code"] == serde_json::json!("no_coverage_collector")), - "{out}" - ); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(true), - "{out}" - ); - } - - #[test] - fn coverage_reports_kcov_lcov_from_untagged_zig_tests() { - let harness = Harness::new("cov-zig-kcov"); - let kcov = "SF:zig/calc.zig\nDA:1,2\nDA:6,0\nLF:2\nLH:1\nend_of_record\n"; - let uri = write_bep_artifact(&harness, "kcov.dat", kcov.as_bytes()); - let configured = serde_json::json!({ - "id": {"targetConfigured": {"label": "//z:t"}}, - "configured": {"targetKind": "_zig_forward_test rule", "testSize": "SMALL", "tag": []}, - }) - .to_string(); - let harness = Harness { - raw_bep: Some(vec![ - configured, - test_result_line("//z:t", &[(String::from("coverage.dat"), uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - "--fail-on=warning", - "--report=lcov=out.lcov", - ]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("no coverage collector"), "{err}"); - let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); - assert_eq!(document, kcov.as_bytes()); - } - - #[test] - fn coverage_reports_kcov_lcov_from_untagged_fortran_tests() { - let harness = Harness::new("cov-fortran-kcov"); - let kcov = "SF:fortran/calc.f90\nDA:1,2\nDA:6,0\nLF:2\nLH:1\nend_of_record\n"; - let uri = write_bep_artifact(&harness, "kcov.dat", kcov.as_bytes()); - let configured = serde_json::json!({ - "id": {"targetConfigured": {"label": "//f:t"}}, - "configured": {"targetKind": "_fortran_forward_test rule", "testSize": "SMALL", "tag": []}, - }) - .to_string(); - let harness = Harness { - raw_bep: Some(vec![ - configured, - test_result_line("//f:t", &[(String::from("coverage.dat"), uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - "--fail-on=warning", - "--report=lcov=out.lcov", - ]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("no coverage collector"), "{err}"); - let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); - assert_eq!(document, kcov.as_bytes()); - } - - #[test] - fn coverage_reports_bisect_lcov_from_untagged_ocaml_tests() { - let harness = Harness::new("cov-ocaml-bisect"); - let lcov = "SF:ocaml/calc.ml\nDA:1,2\nDA:6,0\nLF:2\nLH:1\nend_of_record\n"; - let uri = write_bep_artifact(&harness, "bisect.dat", lcov.as_bytes()); - let configured = serde_json::json!({ - "id": {"targetConfigured": {"label": "//o:t"}}, - "configured": {"targetKind": "_ocaml_forward_test rule", "testSize": "SMALL", "tag": []}, - }) - .to_string(); - let harness = Harness { - raw_bep: Some(vec![ - configured, - test_result_line("//o:t", &[(String::from("coverage.dat"), uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&[ - "coverage", - "--output=text", - "--fail-on=warning", - "--report=lcov=out.lcov", - ]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("no coverage collector"), "{err}"); - let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); - assert_eq!(document, lcov.as_bytes()); - } - - #[test] - fn test_ignores_the_no_collector_tag() { - let harness = Harness::new("test-no-collector"); - let uri = write_bep_artifact(&harness, "zig.xml", MINIMAL_TEST_XML.as_bytes()); - let harness = Harness { - raw_bep: Some(vec![ - no_collector_configured("//z:t"), - test_result_line("//z:t", &[(String::from("test.xml"), uri)]), - ]), - ..harness - }; - let (code, _, err) = harness.run(&["test", "--output=text"]); - assert_eq!(code, 0, "{err}"); - assert!(!err.contains("no_coverage_collector"), "{err}"); - } - - struct BrokenPipe; - - impl std::io::Write for BrokenPipe { - fn write(&mut self, _buf: &[u8]) -> std::io::Result { - Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "broken pipe", - )) - } - - fn flush(&mut self) -> std::io::Result<()> { - Err(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "broken pipe", - )) - } - } - - #[test] - fn announce_incomplete_returns_the_stdout_code_when_stdout_breaks() { - let mut err = Vec::new(); - assert_eq!( - super::announce_incomplete( - dx_output::OutputMode::Json, - &mut BrokenPipe, - &mut err, - "no test.xml" - ), - Err(dx_process::broken_pipe_code()) - ); - } - - #[test] - fn coverage_threshold_returns_the_stdout_code_when_stdout_breaks() { - let mut err = Vec::new(); - let account = dx_lcov::CellAccount::default(); - assert_eq!( - super::coverage_threshold( - Some(80), - Some(&account), - &super::Sources::default(), - dx_output::OutputMode::Json, - &mut BrokenPipe, - &mut err, - ), - Err(dx_process::broken_pipe_code()) - ); - } - - #[test] - fn an_outcome_that_fails_to_build_ends_the_stream_once() { - let outcome = dx_output::TestOutcome { - outcome: "failed".to_owned(), - ..dx_output::TestOutcome::default() - }; - let mut out = Vec::new(); - let mut err = Vec::new(); - let ended = super::emit_outcomes( - dx_output::OutputMode::Json, - &mut out, - &mut err, - &[outcome], - &[], - ); - assert_eq!(ended.err(), Some(dx_process::operational_code())); - let events: Vec = String::from_utf8(out) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - let kinds: Vec<&str> = events - .iter() - .map(|event| event["event"].as_str().unwrap()) - .collect(); - assert_eq!(kinds, ["error", "command_finished"], "{events:?}"); - assert_eq!(events[0]["code"], "invalid_result"); - } -} +#[path = "test_reports_tests.rs"] +mod tests; diff --git a/cli/cli/src/exec/test_reports_tests.rs b/cli/cli/src/exec/test_reports_tests.rs new file mode 100644 index 0000000000..af40f0253c --- /dev/null +++ b/cli/cli/src/exec/test_reports_tests.rs @@ -0,0 +1,4423 @@ +#[test] +fn log_tail_keeps_last_forty_lines() { + let text: String = (1..=50).map(|n| format!("line {n}\n")).collect(); + let want: Vec = (11..=50).map(|n| format!("line {n}")).collect(); + assert_eq!(super::log_tail(text.as_bytes()), Some(want.join("\n"))); +} + +#[test] +fn log_tail_returns_short_logs_whole() { + assert_eq!(super::log_tail(b"a\nb\nc\n"), Some("a\nb\nc".to_owned())); +} + +#[test] +fn log_tail_drops_partial_first_line_when_capped() { + let lines: Vec = (0..40) + .map(|n| format!("{n:02}{}", "x".repeat(298))) + .collect(); + let tail = super::log_tail(lines.join("\n").as_bytes()).unwrap(); + assert!(tail.len() <= super::LOG_TAIL_BYTES); + let got: Vec<&str> = tail.split('\n').collect(); + assert!(got.iter().all(|l| l.len() == 300)); + assert_eq!( + got, + lines[40 - got.len()..] + .iter() + .map(String::as_str) + .collect::>() + ); + assert!(!tail.starts_with('\n')); +} + +#[test] +fn log_tail_cuts_one_oversize_multibyte_line_at_char_boundary() { + let tail = super::log_tail("日".repeat(3500).as_bytes()).unwrap(); + assert!(!tail.is_empty() && tail.len() <= super::LOG_TAIL_BYTES); +} + +#[test] +fn log_tail_is_none_for_blank_input() { + assert_eq!(super::log_tail(b""), None); + assert_eq!(super::log_tail(b" \n\t\n\n"), None); +} + +#[test] +fn log_tail_tolerates_invalid_utf8() { + assert!(super::log_tail(b"ok\n\xff\xfe bad\n").is_some()); +} + +use super::super::test_support::*; +use super::stable_exec_path; +use std::path::Path; + +#[test] +fn coverage_sources_returns_the_write_code_when_stdout_fails() { + let account = dx_lcov::CellAccount::default(); + let got = super::coverage_sources( + &super::Sources::default(), + &account, + dx_output::OutputMode::Json, + false, + &mut BrokenPipe, + &mut Vec::new(), + ); + assert_eq!(got, Err(dx_process::broken_pipe_code())); +} + +#[test] +fn an_exec_path_reads_the_same_on_every_host() { + let execroot = "/home/someone/.cache/bazel/_bazel_x/abc123/execroot/_main"; + assert_eq!( + stable_exec_path(Path::new(&format!( + "{execroot}/bazel-out/k8-fastbuild/bin/env/shard/doctor_test/test.xml" + ))), + "env/shard/doctor_test/test.xml" + ); + assert_eq!( + stable_exec_path(Path::new(&format!( + "{execroot}/bazel-out/x64_windows-fastbuild/bin/env/shard/doctor_test/test.xml" + ))), + "env/shard/doctor_test/test.xml", + "the configuration does not reach the report" + ); + assert_eq!( + stable_exec_path(Path::new(&format!("{execroot}/bin/env/shard/test.xml"))), + "env/shard/test.xml" + ); +} + +#[test] +fn a_windows_exec_path_keeps_its_forward_slashes() { + assert_eq!( + stable_exec_path(Path::new( + r"C:/bz/out/execroot/_main/bazel-out/x64_windows-fastbuild/bin/env/shard/test.xml" + )), + "env/shard/test.xml" + ); +} + +#[test] +fn a_path_outside_the_build_directory_keeps_its_file_name() { + assert_eq!( + stable_exec_path(Path::new("/tmp/somewhere/test.xml")), + "test.xml" + ); + assert_eq!(stable_exec_path(Path::new("test.xml")), "test.xml"); +} + +/// A local `file://` URI for a path that is not there, spelled the way this host spells one. +/// +/// Windows only reads a local `file://` URI back as a path when it carries a drive letter, so +/// the rooted spelling every other host accepts would read as a remote host there. +fn missing_uri(name: &str) -> String { + if cfg!(windows) { + format!("file:///C:/nonexistent/{name}") + } else { + format!("file:///nonexistent/{name}") + } +} + +const MINIMAL_TEST_XML: &str = r#""#; + +const MINIMAL_LCOV: &str = "SF:src/a.py\nDA:1,1\nend_of_record\n"; + +#[test] +fn test_junit_file_report_succeeds() { + let harness = Harness::new("test-junit"); + let uri = write_bep_artifact(&harness, "test.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}"); + assert!(out.contains("Wrote junit report to out.xml.")); + let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); + let text = String::from_utf8(document).expect("utf8"); + assert!(text.contains(" Harness { + write_manifest_sources(&harness, manifest); + let uri = write_bep_artifact(&harness, "sources.txt", manifest.as_bytes()); + let mut lines = vec![ + sources_set("s1", &uri), + sources_completed("//a:lib", true, "s1"), + ]; + lines.extend(harness.raw_bep.clone().unwrap_or_default()); + Harness { + raw_bep: Some(lines), + ..harness + } +} + +fn sources_completed(label: &str, success: bool, set: &str) -> String { + let mut completed = serde_json::json!({"success": success}); + if success { + completed["outputGroup"] = + serde_json::json!([{"name": "dx_coverage_sources", "fileSets": [{"id": set}]}]); + } + serde_json::json!({ + "id": {"targetCompleted": {"label": label, "aspect": SOURCES_ASPECT}}, + "completed": completed, + }) + .to_string() +} + +fn sources_set(set: &str, uri: &str) -> String { + serde_json::json!({ + "id": {"namedSet": {"id": set}}, + "namedSetOfFiles": {"files": [{"name": "lib.dx_coverage_sources.txt", "uri": uri}]}, + }) + .to_string() +} + +/// Prepends a manifest owning `src/a.py`, so `--min-coverage` can account the run's sources. +fn owning_a(mut harness: Harness) -> Harness { + write_manifest_sources(&harness, "prod source src/a.py\n"); + let uri = write_bep_artifact( + &harness, + "sources.txt", + b"v1 cell linux_x86_64\nprod source src/a.py\n", + ); + let lines = [ + sources_set("s1", &uri), + sources_completed("//a:lib", true, "s1"), + ]; + if let Some(bep) = harness.raw_bep.as_mut() { + bep.splice(0..0, lines); + } + harness +} + +fn sources_harness(name: &str, manifest: &str, lcov: &str) -> Harness { + let harness = Harness::new(name); + write_manifest_sources(&harness, manifest); + let lcov_uri = write_bep_artifact(&harness, "coverage.dat", lcov.as_bytes()); + let manifest_uri = write_bep_artifact(&harness, "sources.txt", manifest.as_bytes()); + Harness { + raw_bep: Some(vec![ + sources_set("s1", &manifest_uri), + sources_completed("//a:lib", true, "s1"), + test_result_line("//a:t", &[(String::from("test.lcov"), lcov_uri)]), + ]), + ..harness + } +} + +#[test] +fn coverage_absent_production_source_is_a_notice() { + let harness = sources_harness( + "cov-sources-absent", + "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", + MINIMAL_LCOV, + ); + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!( + err.contains( + "dx: coverage_sources_incomplete: production source absent from coverage report: src/b.py" + ), + "{err}" + ); +} + +#[test] +fn coverage_sources_report_lists_owned_and_absent() { + let harness = sources_harness( + "cov-sources-report", + "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\ntest source src/a_test.py\n", + MINIMAL_LCOV, + ); + let path = harness.workspace.join("sources.json"); + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + &format!("--report=sources={}", path.display()), + ]); + assert_eq!(code, 0, "{err}"); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).expect("sources report")).unwrap(); + assert_eq!(document["record_version"], serde_json::json!(2)); + assert_eq!(document["orphans"], serde_json::json!([])); + assert_eq!(document["absent"], serde_json::json!(["src/b.py"])); + assert_eq!(document["owned"]["src/a.py"], serde_json::json!("eligible")); + assert_eq!( + document["owned"]["src/a_test.py"], + serde_json::json!("support") + ); +} + +const ORPHAN_MANIFEST: &str = "v1 cell linux_x86_64\nprod source src/a.py\n"; +const ORPHAN_MESSAGE: &str = "tracked source not owned by any Bazel target: src/orphan.py"; + +/// Scripts a clean Git checkout whose tracked files include a used and an unused source. +fn script_checkout(harness: &Harness) { + std::fs::create_dir_all(harness.workspace.join("src")).expect("mkdir"); + std::fs::write(harness.workspace.join("src/BUILD.bazel"), "").expect("build"); + harness.query.script_checkout_git(0, "true\n"); + harness.query.script_checkout_git(0, "abc123\n"); + harness.query.script_checkout_git(0, ""); + harness.query.script_checkout_git(0, "true\n"); + harness + .query + .script_checkout_git(0, "src/a.py\0src/orphan.py\0src/BUILD.bazel\0"); + harness.query.script_graph(&[dx_testing::cli::graph_rule( + "//src:lib", + &["//src:a.py"], + &[], + )]); +} + +#[test] +fn coverage_tracked_orphan_fails_min_coverage() { + let harness = sources_harness("cov-orphan-min", ORPHAN_MANIFEST, MINIMAL_LCOV); + script_checkout(&harness); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains(ORPHAN_MESSAGE), "{err}"); +} + +#[test] +fn coverage_tracked_orphan_is_a_notice_without_minimum() { + let harness = sources_harness("cov-orphan-notice", ORPHAN_MANIFEST, MINIMAL_LCOV); + script_checkout(&harness); + let path = harness.workspace.join("sources.json"); + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + &format!("--report=sources={}", path.display()), + ]); + assert_eq!(code, 0, "{err}"); + assert!(err.contains(ORPHAN_MESSAGE), "{err}"); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).expect("sources report")).unwrap(); + assert_eq!(document["orphans"], serde_json::json!(["src/orphan.py"])); + assert_eq!( + document["candidate"], + serde_json::json!({"sha": "abc123", "dirty": false}) + ); +} + +#[test] +fn coverage_tracked_check_skips_scoped_runs() { + let harness = sources_harness("cov-orphan-scoped", ORPHAN_MANIFEST, MINIMAL_LCOV); + script_checkout(&harness); + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + "--min-coverage=100", + "//src:lib", + ]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains(ORPHAN_MESSAGE), "{err}"); +} + +#[test] +fn coverage_tracked_check_skips_non_git_workspace() { + let harness = sources_harness("cov-orphan-nogit", ORPHAN_MANIFEST, MINIMAL_LCOV); + harness.query.script_checkout_git(128, ""); + harness.query.script_checkout_git(128, ""); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 0, "{err}"); + assert!( + err.contains("dx: tracked sources unavailable: not a Git work tree"), + "{err}" + ); +} + +#[test] +fn coverage_tracked_query_failure_fails_min_coverage() { + let harness = sources_harness("cov-orphan-query", ORPHAN_MANIFEST, MINIMAL_LCOV); + script_checkout(&harness); + harness.query.outputs.borrow_mut().clear(); + harness.query.script_failure(1, "ERROR: boom\n"); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("tracked sources unavailable:"), "{err}"); +} + +#[test] +fn coverage_event_carries_sha_dirty_and_collector() { + let mut harness = sources_harness("cov-event-candidate", ORPHAN_MANIFEST, MINIMAL_LCOV); + script_checkout(&harness); + harness.raw_bep.as_mut().expect("bep").insert( + 0, + r#"{"id":{"started":{}},"started":{"buildToolVersion":"9.0.0"}}"#.to_owned(), + ); + let (code, out, err) = harness.run(&["coverage", "--output=json"]); + assert_eq!(code, 0, "{err}"); + let event = out + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["event"] == "coverage") + .expect("coverage event"); + assert_eq!(event["sha"], "abc123"); + assert_eq!(event["dirty"], false); + assert_eq!(event["collector"], "bazel 9.0.0"); +} + +#[test] +fn coverage_zero_hit_line_fails_min_coverage() { + let harness = sources_harness("cov-sources-zero-hit", PY_SOURCES, HALF_LCOV); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains( + "dx: coverage 50.00% (1/2 lines) below minimum 100% in package linux_x86_64 (coverage_below_minimum)" + ), + "{err}" + ); +} + +#[test] +fn coverage_report_without_manifests_fails_min_coverage() { + let harness = coverage_harness("cov-sources-none", MINIMAL_LCOV.as_bytes()); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("coverage_below_minimum"), "{err}"); + assert!( + err.contains("no coverage source manifests were reported"), + "{err}" + ); +} + +#[test] +fn coverage_event_reports_the_cell_account() { + let harness = sources_harness( + "cov-sources-event", + "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", + MINIMAL_LCOV, + ); + let (code, out, _) = harness.run(&["coverage", "--output=json", "--min-coverage=100"]); + assert_eq!(code, 1, "{out}"); + let events: Vec = out + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let coverage: Vec<&serde_json::Value> = events + .iter() + .filter(|event| event["event"] == "coverage") + .collect(); + assert_eq!(coverage.len(), 1, "{out}"); + let event = coverage[0]; + assert_eq!(event["cell"], "linux_x86_64", "{event}"); + assert_eq!(event["covered_lines"], 1, "{event}"); + assert_eq!(event["eligible_lines"], 1, "{event}"); + assert_eq!(event["minimum"], 100, "{event}"); + assert_eq!(event["result"], "fail", "{event}"); + assert_eq!(event["errors"], 1, "{event}"); + assert_eq!(event["dx_version"], dx_adopt::DX_VERSION, "{event}"); + assert!( + events + .iter() + .any(|event| event["code"] == "coverage_below_minimum"), + "{out}" + ); +} + +#[test] +fn coverage_event_passes_a_clean_cell_without_minimum() { + let harness = sources_harness("cov-sources-event-clean", PY_SOURCES, MINIMAL_LCOV); + let (code, out, _) = harness.run(&["coverage", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let event = out + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["event"] == "coverage") + .unwrap_or_else(|| panic!("no coverage event: {out}")); + assert_eq!(event["result"], "pass", "{event}"); + assert!(event.get("minimum").is_none(), "{event}"); +} + +#[test] +fn coverage_sources_counts_line_and_silent_test_sources() { + let harness = sources_harness( + "cov-sources-counts", + "v1 cell linux_x86_64\nprod source src/a.py\ntest source src/a_test.py\n", + MINIMAL_LCOV, + ); + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!( + err.contains( + "dx: coverage sources linux_x86_64: 1 raw, 0 excluded, 1 eligible, 1 covered lines; 1 eligible, 1 support, 0 excluded, 0 unsupported files" + ), + "{err}" + ); + assert!(!err.contains("coverage_sources_incomplete"), "{err}"); +} + +#[test] +fn coverage_sources_counts_perl_sources() { + let harness = sources_harness( + "cov-sources-perl", + "v1 cell linux_x86_64\nprod source src/a.pm\ntest source src/a_test.pl\n", + "SF:src/a.pm\nDA:1,1\nend_of_record\n", + ); + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!( + err.contains( + "1 eligible, 1 covered lines; 1 eligible, 1 support, 0 excluded, 0 unsupported files" + ), + "{err}" + ); +} + +#[test] +fn coverage_sources_notice_is_a_json_event() { + let harness = sources_harness( + "cov-sources-json", + "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", + MINIMAL_LCOV, + ); + let (code, out, _) = harness.run(&["coverage", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let notice = out + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["code"] == "coverage_sources_incomplete") + .unwrap_or_else(|| panic!("no notice: {out}")); + assert_eq!(notice["event"], "notice", "{notice}"); + assert_eq!(notice["severity"], "warning", "{notice}"); + assert_eq!( + notice["message"], + "production source absent from coverage report: src/b.py" + ); + let counts = out + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["code"] == "coverage_sources") + .unwrap_or_else(|| panic!("no counts notice: {out}")); + assert_eq!(counts["severity"], "info", "{counts}"); + assert!( + counts["message"] + .as_str() + .is_some_and(|message| message.starts_with("coverage sources linux_x86_64: ")), + "{counts}" + ); +} + +#[test] +fn coverage_invalid_source_manifest_is_a_notice() { + let harness = sources_harness("cov-sources-invalid", "v1 cell unknown\n", MINIMAL_LCOV); + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!( + err.contains("dx: coverage_sources_incomplete: invalid coverage source manifest"), + "{err}" + ); + assert!(err.contains("unknown cell"), "{err}"); +} + +#[test] +fn coverage_absent_production_source_fails_min_coverage() { + let harness = sources_harness( + "cov-sources-absent-gate", + "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", + MINIMAL_LCOV, + ); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!(!err.contains("meets minimum"), "{err}"); + assert!( + err.contains( + "dx: coverage 100.00% (1/1 lines) in package linux_x86_64 has 1 coverage source gap (see" + ), + "{err}" + ); + assert!( + err.contains( + "dx: coverage_sources_incomplete: production source absent from coverage report: src/b.py" + ), + "{err}" + ); +} + +#[test] +fn coverage_unowned_report_path_fails_min_coverage() { + let harness = sources_harness( + "cov-sources-unowned-gate", + "v1 cell linux_x86_64\nprod source src/other.py\n", + "SF:src/a.py\nDA:1,1\nend_of_record\nSF:src/other.py\nDA:1,1\nend_of_record\n", + ); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains( + "dx: coverage_sources_incomplete: coverage report path not owned by any target: src/a.py" + ), + "{err}" + ); +} + +#[test] +fn coverage_invalid_source_manifest_fails_min_coverage() { + let harness = sources_harness( + "cov-sources-invalid-gate", + "v1 cell unknown\n", + MINIMAL_LCOV, + ); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("dx: coverage_sources_incomplete: invalid coverage source manifest"), + "{err}" + ); +} + +#[test] +fn coverage_sources_error_is_a_json_error_under_min_coverage() { + let harness = sources_harness( + "cov-sources-json-gate", + "v1 cell linux_x86_64\nprod source src/a.py\nprod source src/b.py\n", + MINIMAL_LCOV, + ); + let (code, out, _) = harness.run(&["coverage", "--output=json", "--min-coverage=100"]); + assert_eq!(code, 1, "{out}"); + let error = out + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["code"] == "coverage_sources_incomplete") + .unwrap_or_else(|| panic!("no error: {out}")); + assert_eq!(error["event"], "error", "{error}"); + assert_eq!( + error["message"], + "production source absent from coverage report: src/b.py" + ); +} + +#[test] +fn coverage_test_only_sources_pass_min_coverage() { + let harness = sources_harness( + "cov-sources-test-only-gate", + "v1 cell linux_x86_64\nprod source src/a.py\ntest source src/a_test.py\n", + MINIMAL_LCOV, + ); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("coverage_sources_incomplete"), "{err}"); +} + +#[test] +fn coverage_missing_source_manifest_blocks_min_coverage() { + let harness = Harness::new("cov-sources-missing"); + let lcov_uri = write_bep_artifact(&harness, "coverage.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + sources_completed("//a:lib", false, "s1"), + test_result_line("//a:t", &[(String::from("test.lcov"), lcov_uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("dx: incomplete_results: //a:lib did not report its coverage source manifest"), + "{err}" + ); + assert!(!err.contains("meets minimum"), "{err}"); +} + +#[test] +fn coverage_unreadable_source_manifest_is_incomplete() { + let harness = Harness::new("cov-sources-unreadable"); + let lcov_uri = write_bep_artifact(&harness, "coverage.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + sources_set("s1", &missing_uri("sources.txt")), + sources_completed("//a:lib", true, "s1"), + test_result_line("//a:t", &[(String::from("test.lcov"), lcov_uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("coverage source manifests are unreadable"), + "{err}" + ); +} + +#[test] +fn test_unreadable_bep_is_operational() { + let mut harness = Harness::new("test-nobep"); + harness.skip_bep = true; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("unreadable_bep"), "{err}"); +} + +#[test] +fn test_invalid_bep_is_operational() { + let harness = Harness { + raw_bep: Some(vec![String::from("not json")]), + ..Harness::new("test-badbep") + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("invalid_bep"), "{err}"); +} + +#[test] +fn test_missing_bep_keeps_bazel_code() { + let mut harness = Harness::new("test-nobep-failed"); + harness.skip_bep = true; + harness.bazel_code = 2; + let (code, out, _) = harness.run(&[ + "test", + "--output=json", + "--report=results=r.json", + "--report=junit=j.xml", + ]); + assert_eq!(code, 2, "{out}"); + let events = json_events(&out); + assert!( + events_of_kind(&events, "notice") + .iter() + .any(|event| event["code"] == "build_events_incomplete"), + "{out}" + ); + let errors: Vec<_> = events_of_kind(&events, "error") + .into_iter() + .map(|event| event["code"].as_str().unwrap_or_default().to_owned()) + .collect(); + assert!(errors.contains(&"incomplete_results".to_owned()), "{out}"); + assert!(errors.contains(&"bazel_failed".to_owned()), "{out}"); + assert!(!out.contains("unreadable_bep"), "{out}"); + assert_eq!( + event(&events, "command_finished")["exit_code"], + serde_json::json!(2) + ); + let document: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("r.json")).expect("results report"), + ) + .expect("results json"); + assert_eq!(document["exit_code"], serde_json::json!(2)); + assert_eq!(document["complete"], serde_json::json!(false)); + assert_eq!(document["results"], serde_json::json!([])); + let junit = std::fs::read_to_string(harness.workspace.join("j.xml")).expect("junit"); + assert!(junit.contains("dx.infrastructure"), "{junit}"); + assert!(junit.contains("build events are unavailable"), "{junit}"); +} + +#[test] +fn test_cut_off_bep_keeps_bazel_code_and_results() { + let harness = Harness::new("test-cutbep-failed"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + bazel_code: 37, + unterminated_bep: true, + raw_bep: Some(vec![ + test_result_line("//a:t", &[(String::from("test.xml"), uri)]), + String::from(r#"{"id":{"testRes"#), + ]), + ..harness + }; + let (code, out, _) = harness.run(&[ + "test", + "--output=json", + "--report=results=r.json", + "--report=junit=j.xml", + ]); + assert_eq!(code, 37, "{out}"); + assert!(!out.contains("invalid_bep"), "{out}"); + let events = json_events(&out); + let incomplete = events_of_kind(&events, "error") + .into_iter() + .find(|event| event["code"] == "incomplete_results") + .expect("incomplete_results"); + assert!( + incomplete["message"] + .as_str() + .is_some_and(|message| message.contains("build events end early: line 2")), + "{out}" + ); + let document: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("r.json")).expect("results report"), + ) + .expect("results json"); + assert_eq!(document["exit_code"], serde_json::json!(37)); + assert_eq!(document["complete"], serde_json::json!(false)); + let results = document["results"].as_array().expect("results"); + assert_eq!(results.len(), 1, "{document}"); + assert_eq!(results[0]["target"], serde_json::json!("//a:t")); + let junit = std::fs::read_to_string(harness.workspace.join("j.xml")).expect("junit"); + assert!(junit.contains(">(); + let mut lines = raw; + for index in 0..2 { + lines.push(test_result_line( + &format!("//a:missing{index}"), + &[( + String::from("test.xml"), + missing_uri(&format!("fraction{index}.xml")), + )], + )); + } + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("incomplete_results: 2 missing or invalid results"), + "{err}" + ); +} + +#[test] +fn test_large_missing_fraction_fails_when_bazel_passed() { + let harness = Harness::new("test-partial-majority"); + let raw = (0..2) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("test.xml"), + write_bep_artifact( + &harness, + &format!("ok6-{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + for index in 0..5 { + lines.push(test_result_line( + &format!("//a:missing{index}"), + &[( + String::from("test.xml"), + missing_uri(&format!("majority{index}.xml")), + )], + )); + } + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("5 missing or invalid results"), "{err}"); +} + +#[test] +fn test_one_missing_and_one_invalid_xml_fails_when_bazel_passed() { + let harness = Harness::new("test-mixed-two"); + let raw = (0..6) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("test.xml"), + write_bep_artifact( + &harness, + &format!("ok4-{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + lines.push(test_result_line( + "//a:bad", + &[( + String::from("test.xml"), + write_bep_artifact(&harness, "bad2.xml", b"not xml"), + )], + )); + lines.push(test_result_line( + "//a:missing", + &[(String::from("test.xml"), missing_uri("missing5.xml"))], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("incomplete_results: 2 missing or invalid results"), + "{err}" + ); +} + +#[test] +fn test_single_missing_xml_fails_when_bazel_failed() { + let harness = Harness::new("test-partial-bazelfail"); + let uri = write_bep_artifact(&harness, "ok2.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:ok", &[(String::from("test.xml"), uri)]), + test_result_line( + "//a:missing", + &[(String::from("test.xml"), missing_uri("missing2.xml"))], + ), + ]), + bazel_code: 1, + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn test_invalid_xml_is_incomplete() { + let harness = Harness::new("test-badxml"); + let uri = write_bep_artifact(&harness, "bad.xml", b"not xml"); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn coverage_ignores_non_lcov_and_reports_missing() { + let harness = Harness::new("cov-ignore"); + let xml = write_bep_artifact(&harness, "x.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), xml)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("no coverage.dat"), "{err}"); +} + +#[test] +fn coverage_report_without_lcov_names_the_gap_not_the_destination() { + let harness = Harness::new("cov-gap-report"); + let xml = write_bep_artifact(&harness, "x.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), xml)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("report_failed"), "{err}"); + assert!(err.contains("no coverage.dat"), "{err}"); + assert!(err.contains("fix the test or coverage problem"), "{err}"); + assert!(!err.contains("writable"), "{err}"); +} + +#[test] +fn coverage_unreadable_is_incomplete() { + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("coverage.dat"), missing_uri("c.dat"))], + )]), + ..Harness::new("cov-unreadable") + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn coverage_single_missing_dat_fails_when_bazel_passed() { + let harness = Harness::new("cov-partial-missing"); + let raw = (0..4) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("coverage.dat"), + write_bep_artifact( + &harness, + &format!("ok7-{index}.dat"), + MINIMAL_LCOV.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + lines.push(test_result_line( + "//a:missing", + &[(String::from("coverage.dat"), missing_uri("missing.dat"))], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!( + err.contains("coverage.dat for //a:missing (run 1, shard 1, attempt 1)"), + "{err}" + ); +} + +#[test] +fn coverage_missing_artifact_blocks_min_coverage() { + let harness = Harness::new("cov-missing-threshold"); + let raw = (0..4) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("coverage.dat"), + write_bep_artifact( + &harness, + &format!("ok8-{index}.dat"), + MINIMAL_LCOV.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + lines.push(test_result_line( + "//a:missing", + &[(String::from("coverage.dat"), missing_uri("missing.dat"))], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!(!err.contains("meets minimum"), "{err}"); +} + +#[test] +fn coverage_invalid_artifact_blocks_min_coverage() { + let harness = Harness::new("cov-invalid-threshold"); + let ok_uri = write_bep_artifact(&harness, "ok.dat", MINIMAL_LCOV.as_bytes()); + let bad_uri = write_bep_artifact(&harness, "bad.dat", b"not lcov"); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:ok", &[(String::from("coverage.dat"), ok_uri)]), + test_result_line("//a:bad", &[(String::from("coverage.dat"), bad_uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!( + err.contains("invalid coverage.dat for //a:bad (run 1, shard 1, attempt 1)"), + "{err}" + ); + assert!(!err.contains("meets minimum"), "{err}"); +} + +#[test] +fn coverage_superseded_attempt_is_not_required() { + let harness = Harness::new("cov-retry-ok"); + let final_uri = write_bep_artifact(&harness, "final.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_identity_line( + "//a:t", + 1, + 1, + 1, + &[(String::from("test.lcov"), missing_uri("attempt1.dat"))], + ), + test_result_identity_line("//a:t", 1, 1, 2, &[(String::from("test.lcov"), final_uri)]), + ]), + ..harness + }; + let harness = owning_a(harness); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("incomplete_results"), "{err}"); + assert!(err.contains("meets minimum 100%"), "{err}"); +} + +#[test] +fn coverage_final_attempt_missing_is_required() { + let harness = Harness::new("cov-retry-missing"); + let stale_uri = write_bep_artifact(&harness, "stale.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_identity_line("//a:t", 1, 1, 1, &[(String::from("test.lcov"), stale_uri)]), + test_result_identity_line( + "//a:t", + 1, + 1, + 2, + &[(String::from("test.lcov"), missing_uri("final.dat"))], + ), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!( + err.contains("test.lcov for //a:t (run 1, shard 1, attempt 2)"), + "{err}" + ); +} + +#[test] +fn coverage_uninstrumented_test_does_not_demand_coverage() { + let harness = Harness::new("cov-uninstrumented"); + let lcov_uri = write_bep_artifact(&harness, "inst.dat", MINIMAL_LCOV.as_bytes()); + let xml_uri = write_bep_artifact(&harness, "plain.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:instrumented", &[(String::from("test.lcov"), lcov_uri)]), + test_result_line("//a:plain", &[(String::from("test.xml"), xml_uri)]), + ]), + ..harness + }; + let harness = owning_a(harness); + let (code, _, err) = harness.run(&["coverage", "--output=text", "--min-coverage=100"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("incomplete_results"), "{err}"); + assert!(err.contains("meets minimum 100%"), "{err}"); +} + +#[test] +fn coverage_partial_report_is_written_and_marked_incomplete() { + let harness = Harness::new("cov-partial-report"); + let ok_uri = write_bep_artifact(&harness, "ok.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:ok", &[(String::from("coverage.dat"), ok_uri)]), + test_result_line( + "//a:missing", + &[(String::from("coverage.dat"), missing_uri("missing.dat"))], + ), + ]), + ..harness + }; + let (code, out, err) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); + assert_eq!(code, 1, "{out}{err}"); + assert!(out.contains("Wrote lcov report to out.lcov."), "{out}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!( + err.contains("coverage.dat for //a:missing"), + "the partial report names the missing target artifact: {err}" + ); + let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); + assert_eq!(document, MINIMAL_LCOV.as_bytes()); +} + +#[test] +fn coverage_incomplete_json_agrees_with_exit_status() { + let harness = Harness::new("cov-incomplete-json"); + let ok_uri = write_bep_artifact(&harness, "ok.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:ok", &[(String::from("coverage.dat"), ok_uri)]), + test_result_line( + "//a:missing", + &[(String::from("coverage.dat"), missing_uri("missing.dat"))], + ), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["coverage", "--output=json", "--min-coverage=100"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("incomplete_results"), "{out}"); + assert!(out.contains("\"results_complete\":false"), "{out}"); + assert!(!out.contains("coverage_below_minimum"), "{out}"); +} + +#[test] +fn coverage_two_missing_dats_fail_when_bazel_passed() { + let harness = Harness::new("cov-partial-two"); + let raw = (0..6) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("coverage.dat"), + write_bep_artifact( + &harness, + &format!("ok3-{index}.dat"), + MINIMAL_LCOV.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + for index in 0..2 { + lines.push(test_result_line( + &format!("//a:missing{index}"), + &[( + String::from("coverage.dat"), + missing_uri(&format!("missing3-{index}.dat")), + )], + )); + } + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!(err.contains("2 missing or invalid results"), "{err}"); +} + +#[test] +fn coverage_one_invalid_and_one_missing_dat_fail_when_bazel_passed() { + let harness = Harness::new("cov-mixed-two"); + let raw = (0..6) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("test.lcov"), + write_bep_artifact( + &harness, + &format!("ok4-{index}.dat"), + MINIMAL_LCOV.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + lines.push(test_result_line( + "//a:bad", + &[( + String::from("test.lcov"), + write_bep_artifact(&harness, "bad3.dat", b"not lcov"), + )], + )); + lines.push(test_result_line( + "//a:missing", + &[(String::from("coverage.dat"), missing_uri("missing5.dat"))], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); + assert!(err.contains("2 missing or invalid results"), "{err}"); +} + +#[test] +fn coverage_large_missing_fraction_fails_when_bazel_passed() { + let harness = Harness::new("cov-partial-majority"); + let raw = (0..2) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("coverage.dat"), + write_bep_artifact( + &harness, + &format!("ok6-{index}.dat"), + MINIMAL_LCOV.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + for index in 0..5 { + lines.push(test_result_line( + &format!("//a:missing{index}"), + &[( + String::from("coverage.dat"), + missing_uri(&format!("covmajority{index}.dat")), + )], + )); + } + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("5 missing or invalid results"), "{err}"); +} + +#[test] +fn coverage_empty_dat_does_not_count_as_missing() { + let harness = Harness::new("cov-empty-ok"); + let empty = write_bep_artifact(&harness, "empty2.dat", b""); + let ok = write_bep_artifact(&harness, "ok5.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:empty", &[(String::from("coverage.dat"), empty)]), + test_result_line("//a:ok", &[(String::from("coverage.dat"), ok)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn coverage_single_missing_dat_fails_when_bazel_failed() { + let harness = Harness::new("cov-partial-bazelfail"); + let uri = write_bep_artifact(&harness, "ok2.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:ok", &[(String::from("coverage.dat"), uri)]), + test_result_line( + "//a:missing", + &[(String::from("coverage.dat"), missing_uri("missing2.dat"))], + ), + ]), + bazel_code: 1, + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn coverage_report_failed_when_incomplete() { + let harness = Harness::new("cov-repfail"); + let uri = write_bep_artifact(&harness, "bad2.dat", b"not lcov"); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.lcov"), uri)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("report_failed"), "{err}"); +} + +#[test] +fn test_report_write_failure_is_operational() { + let harness = Harness::new("test-writefail"); + let uri = write_bep_artifact(&harness, "w.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&[ + "test", + "--output=text", + "--report=junit=missing-dir/out.xml", + ]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("report_failed"), "{err}"); +} + +#[test] +fn test_report_json_emits_report_event() { + let harness = Harness::new("test-jsonrep"); + let uri = write_bep_artifact(&harness, "j.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}"); + assert!(out.contains("report"), "{out}"); + assert!(out.contains("command_finished"), "{out}"); +} + +#[test] +fn test_rejects_diff_output_at_parse() { + let err = dx_plan::args::parse( + &["test", "--diff", "--report=junit=out.xml"] + .iter() + .map(ToString::to_string) + .collect::>(), + ) + .expect_err("diff rejected"); + assert_eq!( + err, + dx_plan::args::ArgsError::UnsupportedOption { + command: "test", + option: "--diff".to_owned(), + } + ); +} + +#[test] +fn test_incomplete_json_reports_incomplete_event() { + let harness = Harness { + raw_bep: Some(vec![String::from( + "{\"id\": {\"testResult\": {\"label\": \"//a:t\"}}, \"testResult\": {\"status\": \"PASSED\"}}", + )]), + ..Harness::new("test-incjson") + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("incomplete_results"), "{out}"); + assert!(out.contains("command_finished"), "{out}"); +} + +#[test] +fn coverage_lcov_without_trailing_newline_gets_newline() { + let harness = Harness::new("cov-nonl"); + let raw = b"SF:src/a.py\nDA:1,1\nend_of_record"; + let uri = write_bep_artifact(&harness, "nonl.dat", raw); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.lcov"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["coverage", "--output=text", "--report=lcov=out.lcov"]); + assert_eq!(code, 0, "{out}"); + let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); + assert!(document.ends_with(b"\n"), "{document:?}"); +} + +#[test] +fn coverage_render_failure_json_reports_error_event() { + let harness = Harness::new("cov-render-json"); + let xml = write_bep_artifact(&harness, "x.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), xml)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["coverage", "--output=json", "--report=lcov=out.lcov"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("report_failed"), "{out}"); +} + +#[test] +fn test_report_write_failure_json_reports_error_event() { + let harness = Harness::new("test-writefail-json"); + let uri = write_bep_artifact(&harness, "w2.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&[ + "test", + "--output=json", + "--report=junit=missing-dir/out.xml", + ]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("report_failed"), "{out}"); +} + +#[test] +fn a_testlogs_path_keeps_its_package_and_identity() { + let execroot = "/home/someone/.cache/bazel/_bazel_x/abc123/execroot/_main"; + assert_eq!( + stable_exec_path(Path::new(&format!( + "{execroot}/bazel-out/k8-fastbuild/testlogs/cli/bep/dx_bep_test/run_2_of_2/test.xml" + ))), + "cli/bep/dx_bep_test/run_2_of_2/test.xml" + ); + assert_eq!( + stable_exec_path(Path::new("/ws/bazel-testlogs/a/t/shard_1_of_2/test.xml")), + "a/t/shard_1_of_2/test.xml" + ); +} + +#[test] +fn test_file_outputs_skip_bazel_info() { + let harness = Harness::new("test-file-no-info"); + let uri = write_bep_artifact(&harness, "test.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}{err}"); + let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); + let text = String::from_utf8(document).expect("utf8"); + assert!(text.contains("tests=\"1\""), "{text}"); + assert!(harness.query.info_calls.borrow().is_empty()); +} + +#[test] +fn test_bytestream_outputs_resolve_through_info_testlogs() { + let harness = Harness::new("test-info-roots"); + let testlogs = harness.temp.join("testlogs"); + harness.query.script_info(&format!( + "bazel-testlogs: {}\nexecution_root: {}\nignored: {}\n", + testlogs.display(), + harness.temp.join("execroot").display(), + harness.temp.display(), + )); + let root = testlogs.join("a").join("t"); + std::fs::create_dir_all(&root).expect("testlogs dir"); + std::fs::write(root.join("test.xml"), MINIMAL_TEST_XML).expect("test.xml"); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[( + String::from("test.xml"), + "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), + )], + )]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}{err}"); + let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); + let text = String::from_utf8(document).expect("utf8"); + assert!(text.contains("tests=\"1\""), "{text}"); + let calls = harness.query.info_calls.borrow(); + assert_eq!(calls.len(), 1, "one bazel info run per report run"); + let argv = &calls[0]; + assert!(argv.contains(&"info".to_owned()), "{argv:?}"); + assert!(argv.contains(&"bazel-testlogs".to_owned()), "{argv:?}"); + assert!(argv.contains(&"execution_root".to_owned()), "{argv:?}"); + assert!(argv.contains(&"output_path".to_owned()), "{argv:?}"); + assert!( + argv.contains(&"--config=dx_build_dev".to_owned()), + "the test profile reaches info: {argv:?}" + ); + assert!( + harness.query.calls.borrow().is_empty(), + "info must not consume query outputs" + ); +} + +#[test] +fn test_bytestream_outputs_resolve_through_configuration_mnemonic() { + let harness = Harness::new("test-info-mnemonic"); + let out = harness.temp.join("out"); + harness.query.script_info(&format!( + "bazel-testlogs: {}\nexecution_root: {}\noutput_path: {}\n", + out.join("k8-fastbuild").join("testlogs").display(), + harness.temp.join("execroot").display(), + out.display(), + )); + let root = out + .join("k8-fastbuild-ST-x") + .join("testlogs") + .join("a") + .join("t"); + std::fs::create_dir_all(&root).expect("testlogs dir"); + std::fs::write(root.join("test.xml"), MINIMAL_TEST_XML).expect("test.xml"); + let configuration = serde_json::json!({ + "id": {"configuration": {"id": "cfgST"}}, + "configuration": {"mnemonic": "k8-fastbuild-ST-x"}, + }) + .to_string(); + let harness = Harness { + raw_bep: Some(vec![ + configuration, + test_result_full_line( + "//a:t", + 1, + 1, + 1, + "PASSED", + Some("cfgST"), + None, + None, + &[( + String::from("test.xml"), + "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), + )], + ), + ]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=json", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}{err}"); + assert!(!out.contains("incomplete_results"), "{out}"); + let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); + let text = String::from_utf8(document).expect("utf8"); + assert!(text.contains("tests=\"1\""), "{text}"); +} + +#[test] +fn test_info_carries_startup_options_before_the_verb() { + let harness = Harness::new("test-info-startup"); + let testlogs = harness.temp.join("testlogs"); + harness.query.script_info(&format!( + "bazel-testlogs: {}\nexecution_root: {}\n", + testlogs.display(), + harness.temp.join("execroot").display(), + )); + let root = testlogs.join("a").join("t"); + std::fs::create_dir_all(&root).expect("testlogs dir"); + std::fs::write(root.join("test.xml"), MINIMAL_TEST_XML).expect("test.xml"); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[( + String::from("test.xml"), + "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), + )], + )]), + ..harness + }; + let (code, _, err) = harness.run(&[ + "test", + "--bazel-startup-option=--output_base=/tmp/a", + "--output=text", + ]); + assert_eq!(code, 0, "{err}"); + let calls = harness.query.info_calls.borrow(); + assert_eq!(calls.len(), 1, "one bazel info run per report run"); + let argv = &calls[0]; + let verb = argv.iter().position(|arg| arg == "info").expect("info"); + assert_eq!( + &argv[..verb], + &[ + "bazel".to_owned(), + "--nohome_rc".to_owned(), + "--nosystem_rc".to_owned(), + "--output_base=/tmp/a".to_owned(), + ] + ); +} + +#[test] +fn test_sharded_and_retried_bytestream_outputs_keep_identity() { + let harness = Harness::new("test-info-identity"); + let testlogs = harness.temp.join("testlogs"); + harness + .query + .script_info(&format!("bazel-testlogs: {}\n", testlogs.display())); + let unit = testlogs.join("a").join("t"); + let attempts = unit.join("shard_1_of_2").join("test_attempts"); + std::fs::create_dir_all(&attempts).expect("attempt dir"); + std::fs::create_dir_all(unit.join("shard_2_of_2")).expect("shard dir"); + std::fs::write(attempts.join("attempt_1.xml"), MINIMAL_TEST_XML).expect("attempt xml"); + std::fs::write(unit.join("shard_1_of_2").join("test.xml"), MINIMAL_TEST_XML) + .expect("shard xml"); + std::fs::write(unit.join("shard_2_of_2").join("test.xml"), MINIMAL_TEST_XML) + .expect("shard xml"); + let entry = || { + vec![( + String::from("test.xml"), + "bytestream://remote.buildbuddy.io/blobs/abc/10".to_owned(), + )] + }; + let harness = Harness { + raw_bep: Some(vec![ + test_summary_line("//a:t", 2), + test_result_identity_line("//a:t", 1, 1, 1, &entry()), + test_result_identity_line("//a:t", 1, 1, 2, &entry()), + test_result_identity_line("//a:t", 1, 2, 1, &entry()), + ]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}{err}"); + let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); + let text = String::from_utf8(document).expect("utf8"); + assert!(text.contains("tests=\"3\""), "{text}"); +} + +#[test] +fn two_runs_of_one_label_render_distinct_case_names() { + let harness = Harness::new("test-junit-runs"); + let first = write_bep_artifact(&harness, "run1.xml", MINIMAL_TEST_XML.as_bytes()); + let second = write_bep_artifact(&harness, "run2.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_identity_line("//a:t", 1, 1, 1, &[(String::from("test.xml"), first)]), + test_result_identity_line("//a:t", 2, 1, 1, &[(String::from("test.xml"), second)]), + ]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}{err}"); + let text = std::fs::read_to_string(harness.workspace.join("out.xml")).expect("junit"); + assert!( + text.contains(r#"\xff"; + let uri = write_bep_artifact(&harness, "test.xml", xml); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + err.contains( + "dx: warning: test.xml for //a:t (run 1, shard 1, attempt 1) has invalid UTF-8" + ), + "{err}" + ); + let text = std::fs::read_to_string(harness.workspace.join("out.xml")).expect("junit"); + assert!(text.contains(r#"tt"#; + +const EMPTY_TEST_XML: &str = r#""#; + +#[test] +fn test_outcomes_carry_bep_status_cache_and_timing() { + let harness = Harness::new("test-outcome-meta"); + let ok_uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let cached_uri = write_bep_artifact(&harness, "cached.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_full_line( + "//a:ok", + 1, + 1, + 1, + "PASSED", + Some("cfg-one"), + Some(false), + Some(12), + &[(String::from("test.xml"), ok_uri)], + ), + test_result_full_line( + "//a:cached", + 2, + 3, + 1, + "FAILED", + None, + Some(true), + None, + &[(String::from("test.xml"), cached_uri)], + ), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["target"], serde_json::json!("//a:cached")); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); + assert_eq!(outcomes[0]["cached"], serde_json::json!(true)); + assert_eq!(outcomes[0]["run"], serde_json::json!(2)); + assert_eq!(outcomes[0]["shard"], serde_json::json!(3)); + assert!(outcomes[0].get("configuration").is_none(), "{out}"); + assert!(outcomes[0].get("duration_millis").is_none(), "{out}"); + let finished_event = event(&events, "command_finished"); + assert_eq!( + finished_event["tests"], + serde_json::json!({"passed": 1, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + assert_eq!(finished_event["exit_code"], serde_json::json!(0)); + assert_eq!(finished_event["results_complete"], serde_json::json!(true)); +} + +#[test] +fn the_passed_count_matches_the_results_report() { + let harness = Harness::new("test-outcome-all-passed"); + let mut lines = Vec::new(); + for index in 0..3 { + let uri = write_bep_artifact( + &harness, + &format!("cases-{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ); + lines.push(test_result_line( + &format!("//rust:compile_test{index}"), + &[(String::from("test.xml"), uri)], + )); + } + for index in 0..4 { + let uri = write_bep_artifact( + &harness, + &format!("empty-{index}.xml"), + EMPTY_TEST_XML.as_bytes(), + ); + lines.push(test_result_line( + &format!("//go:generation_test{index}"), + &[(String::from("test.xml"), uri)], + )); + } + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert!(outcomes.is_empty(), "{out}"); + let results: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json"); + let entries = results["results"].as_array().expect("results"); + assert_eq!(entries.len(), 7, "{results}"); + assert!( + entries + .iter() + .all(|entry| entry["bazel_status"] == "PASSED"), + "{results}" + ); + let no_cases = entries + .iter() + .filter(|entry| entry["outcome"] == "no_cases") + .count(); + assert_eq!(no_cases, 4, "{results}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 7, "failed": 0, "cached": 0, "no_cases": 4, "skipped": 0}) + ); +} + +#[test] +fn test_outcome_counts_cases_by_severity() { + let harness = Harness::new("test-outcome-cases"); + let uri = write_bep_artifact(&harness, "mixed.xml", MIXED_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + None, + None, + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); + assert_eq!(outcomes[0]["cases_total"], serde_json::json!(4)); + assert_eq!(outcomes[0]["cases_failed"], serde_json::json!(1)); + assert_eq!(outcomes[0]["cases_error"], serde_json::json!(1)); + assert_eq!(outcomes[0]["cases_skipped"], serde_json::json!(1)); + assert!(outcomes[0].get("artifacts").is_none(), "{out}"); + assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); + let failing = outcomes[0]["failing_cases"].as_array().expect("failing"); + let names: Vec<_> = failing.iter().map(|case| case["name"].clone()).collect(); + assert_eq!( + names, + vec![serde_json::json!("fails"), serde_json::json!("errors")] + ); +} + +#[test] +fn test_flaky_result_reports_passed_after_retry() { + let harness = Harness::new("test-outcome-flaky"); + let first_uri = write_bep_artifact(&harness, "first.xml", MINIMAL_TEST_XML.as_bytes()); + let retry_uri = write_bep_artifact(&harness, "retry.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + Some(false), + None, + &[(String::from("test.xml"), first_uri)], + ), + test_result_full_line( + "//a:t", + 1, + 1, + 2, + "FLAKY", + None, + Some(false), + None, + &[(String::from("test.xml"), retry_uri)], + ), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 2, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); + assert_eq!(outcomes[0]["attempt"], serde_json::json!(1)); + assert_eq!(outcomes[0]["final"], serde_json::json!(false), "{out}"); + assert_eq!( + outcomes[1]["outcome"], + serde_json::json!("passed_after_retry") + ); + assert_eq!(outcomes[1]["attempt"], serde_json::json!(2)); + assert!(outcomes[1].get("final").is_none(), "{out}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + let read_results = || -> serde_json::Value { + serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json") + }; + let results = read_results(); + let entries = results["results"].as_array().expect("results"); + assert_eq!(entries.len(), 2, "{results}"); + assert_eq!(entries[0]["attempt"], serde_json::json!(1)); + assert_eq!(entries[0]["final"], serde_json::json!(false), "{results}"); + assert_eq!(entries[1]["attempt"], serde_json::json!(2)); + assert!(entries[1].get("final").is_none(), "{results}"); + let (code, out, err) = harness.run(&["test", "--output=text", "--report=results=res.json"]); + assert_eq!(code, 0, "{out}{err}"); + let results = read_results(); + let entries = results["results"].as_array().expect("results"); + assert_eq!(entries.len(), 2, "{results}"); + assert_eq!(entries[0]["attempt"], serde_json::json!(1)); + assert_eq!(entries[0]["final"], serde_json::json!(false), "{results}"); + assert!(entries[1].get("final").is_none(), "{results}"); +} + +#[test] +fn test_flaky_summary_marks_retried_pass_distinctly() { + let harness = Harness::new("test-summary-flaky"); + let first_uri = write_bep_artifact(&harness, "first.xml", MINIMAL_TEST_XML.as_bytes()); + let retry_uri = write_bep_artifact(&harness, "retry.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + Some(false), + None, + &[(String::from("test.xml"), first_uri)], + ), + test_result_full_line( + "//a:t", + 1, + 1, + 2, + "PASSED", + None, + Some(false), + None, + &[(String::from("test.xml"), retry_uri)], + ), + test_flaky_summary_line("//a:t"), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 2, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); + assert_eq!(outcomes[0]["attempt"], serde_json::json!(1)); + assert_eq!( + outcomes[1]["outcome"], + serde_json::json!("passed_after_retry") + ); + assert_eq!(outcomes[1]["attempt"], serde_json::json!(2)); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) + ); +} + +#[test] +fn test_flaky_success_is_visible_in_text_summary() { + let harness = Harness::new("test-flaky-text"); + let first_uri = write_bep_artifact(&harness, "first.xml", MINIMAL_TEST_XML.as_bytes()); + let retry_uri = write_bep_artifact(&harness, "retry.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + Some(false), + None, + &[(String::from("test.xml"), first_uri)], + ), + test_result_full_line( + "//a:t", + 1, + 1, + 2, + "PASSED", + None, + Some(false), + None, + &[(String::from("test.xml"), retry_uri)], + ), + test_flaky_summary_line("//a:t"), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!( + err.contains("dx: flaky success (passed after retry): //a:t (attempt 2)"), + "{err}" + ); +} + +#[test] +fn test_clean_pass_prints_no_flaky_summary() { + let harness = Harness::new("test-clean-text"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("flaky success"), "{err}"); +} + +#[test] +fn flaky_summary_without_result_prints_no_attempt() { + let harness = Harness { + raw_bep: Some(vec![test_flaky_summary_line("//a:t")]), + ..Harness::new("test-flaky-summary-only") + }; + let (_, _, err) = harness.run(&["test", "--output=text"]); + assert!( + err.contains("dx: flaky success (passed after retry): //a:t\n"), + "{err}" + ); + assert!(!err.contains("(attempt"), "{err}"); +} + +#[test] +fn later_planned_report_event_is_incomplete_after_an_earlier_write_failure() { + let harness = Harness::new("test-writefail-later"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&[ + "test", + "--output=json", + "--report=results=missing-dir/r.json", + "--report=junit=j.xml", + ]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let reports = events_of_kind(&events, "report"); + assert_eq!(reports.len(), 1, "{out}"); + assert_eq!(reports[0]["format"], serde_json::json!("junit"), "{out}"); + assert_eq!( + reports[0]["results_complete"], + serde_json::json!(false), + "{out}" + ); +} + +fn evidence(problems: &[&str]) -> super::Evidence { + let mut evidence = super::Evidence::default(); + for problem in problems { + evidence.record((*problem).to_owned()); + } + evidence +} + +#[test] +fn evidence_gap_names_what_made_results_incomplete() { + assert_eq!( + super::evidence_gap(true, &evidence(&[]), "none reported"), + Some("none reported".to_owned()) + ); + assert_eq!( + super::evidence_gap(true, &evidence(&["a", "b"]), "none reported"), + Some("a".to_owned()) + ); + assert_eq!( + super::evidence_gap(false, &evidence(&["a", "b"]), "none reported"), + Some("2 missing or invalid results: a".to_owned()) + ); + assert_eq!( + super::evidence_gap(false, &evidence(&[]), "none reported"), + None + ); +} + +fn located(xml: &str) -> Vec<(Option, Option)> { + let cases = dx_plan::reports::parse_test_xml(xml.as_bytes(), 0, 0).expect("cases"); + cases + .iter() + .map(|case| { + let found = super::test_case(case); + (found.file, found.line) + }) + .collect() +} + +#[test] +fn a_case_takes_file_and_line_from_the_report_attributes() { + let xml = r#"trace"#; + assert_eq!(located(xml), [(Some("t/a_test.py".to_owned()), Some(7))]); +} + +#[test] +fn a_case_takes_file_and_line_from_the_failure_message() { + let xml = r#"tat Foo.method(Foo.java:17)"#; + assert_eq!( + located(xml), + [ + (Some("foo_test.go".to_owned()), Some(42)), + (Some("Foo.java".to_owned()), Some(17)), + ] + ); +} + +#[test] +fn a_case_without_a_location_leaves_file_and_line_unset() { + let xml = r#"t"#; + assert_eq!(located(xml), [(None, None), (None, None)]); +} + +#[test] +fn parse_cases_stamps_run_and_configuration() { + let result = dx_bep::TestResultRecord { + label: "//a:t".to_owned(), + configuration: Some("cfg".to_owned()), + run: 2, + shard: 3, + attempt: 1, + status: Some("PASSED".to_owned()), + cached_locally: None, + duration_millis: None, + strategy: None, + files: Vec::new(), + }; + let cases = super::parse_cases(&result, MINIMAL_TEST_XML.as_bytes()).expect("cases"); + assert!(!cases.is_empty()); + for case in &cases { + assert_eq!(case.run, 1); + assert_eq!(case.configuration.as_deref(), Some("cfg")); + } + assert!(super::parse_cases(&result, b"no elements").is_err()); +} + +#[test] +fn coverage_summary_prints_the_total_and_the_worst_files() { + let mut account = dx_lcov::CellAccount { + eligible_lines: 20, + covered_lines: 13, + ..dx_lcov::CellAccount::default() + }; + for (path, counts) in [("a.rs", (10, 10)), ("b.rs", (3, 6)), ("c.rs", (0, 4))] { + account.file_lines.insert(path.to_owned(), counts); + } + let text = dx_output::OutputMode::Text { quiet: false }; + let mut err = Vec::new(); + super::coverage_summary(&account, true, text, &mut err); + let got = String::from_utf8(err).unwrap(); + assert!(got.contains("dx: coverage 65.0% (13/20 lines)"), "{got}"); + assert!(!got.contains("a.rs"), "{got}"); + assert!( + got.find("c.rs").unwrap() < got.find("b.rs").unwrap(), + "{got}" + ); + let mut err = Vec::new(); + super::coverage_summary(&account, false, text, &mut err); + assert!(!String::from_utf8(err).unwrap().contains("dx: coverage 65")); + let mut err = Vec::new(); + super::coverage_summary(&account, true, dx_output::OutputMode::Json, &mut err); + assert!(err.is_empty()); +} + +#[test] +fn coverage_threshold_passes_without_minimum_and_fails_on_incomplete_evidence() { + let mut out = Vec::new(); + let mut err = Vec::new(); + let sources = super::Sources::default(); + let text = dx_output::OutputMode::Text { quiet: false }; + assert_eq!( + super::coverage_threshold(None, None, &sources, text, &mut out, &mut err), + Ok(true) + ); + assert_eq!( + super::coverage_threshold(Some(80), None, &sources, text, &mut out, &mut err), + Ok(false) + ); + assert!(out.is_empty() && err.is_empty()); +} + +#[test] +fn test_timeout_and_failure_outcomes_stay_distinct() { + let harness = Harness::new("test-outcome-statuses"); + let timeout_uri = write_bep_artifact(&harness, "timeout.xml", MINIMAL_TEST_XML.as_bytes()); + let failed_uri = write_bep_artifact(&harness, "failed.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_full_line( + "//a:slow", + 1, + 1, + 1, + "TIMEOUT", + None, + None, + None, + &[(String::from("test.xml"), timeout_uri)], + ), + test_result_full_line( + "//a:bad", + 1, + 1, + 1, + "FAILED", + None, + None, + None, + &[(String::from("test.xml"), failed_uri)], + ), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 2, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed")); + assert_eq!(outcomes[1]["outcome"], serde_json::json!("timeout")); + assert_eq!(outcomes[1]["bazel_status"], serde_json::json!("TIMEOUT")); +} + +#[test] +fn test_missing_status_reports_unknown_outcome() { + let harness = Harness::new("test-outcome-unknown"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let line = serde_json::json!({ + "id": {"testResult": {"label": "//a:t", "run": 1, "shard": 1, "attempt": 1}}, + "testResult": {"testActionOutput": [{"name": "test.xml", "uri": uri}]}, + }) + .to_string(); + let harness = Harness { + raw_bep: Some(vec![line]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("unknown")); + assert!(outcomes[0].get("bazel_status").is_none(), "{out}"); + assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); +} + +#[test] +fn test_zero_case_xml_reports_zero_counts() { + let harness = Harness::new("test-outcome-zero"); + let uri = write_bep_artifact(&harness, "empty.xml", EMPTY_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + assert!(events_of_kind(&events, "test_outcome").is_empty(), "{out}"); + let results: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json"); + let entry = &results["results"][0]; + assert_eq!(entry["outcome"], serde_json::json!("no_cases"), "{results}"); + assert_eq!(entry["cases_total"], serde_json::json!(0), "{results}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 1, "skipped": 0}) + ); +} + +#[test] +fn test_cached_no_cases_result_counts_cached_and_no_cases() { + let harness = Harness::new("test-outcome-cached-zero"); + let uri = write_bep_artifact( + &harness, + "empty.xml", + "".as_bytes(), + ); + let harness = Harness { + raw_bep: Some(vec![test_result_full_line( + "//a:t", + 1, + 1, + 1, + "PASSED", + None, + Some(true), + None, + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + assert!(events_of_kind(&events, "test_outcome").is_empty(), "{out}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 1, "no_cases": 1, "skipped": 0}) + ); +} + +#[test] +fn test_status_only_result_is_incomplete_evidence() { + let harness = Harness { + raw_bep: Some(vec![String::from( + "{\"id\": {\"testResult\": {\"label\": \"//a:t\"}}, \"testResult\": {\"status\": \"PASSED\"}}", + )]), + ..Harness::new("test-outcome-nofile") + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("passed")); + assert_eq!(outcomes[0]["evidence_complete"], serde_json::json!(false)); + assert_eq!(outcomes[0]["artifacts"]["missing"], serde_json::json!(1)); + let finished = event(&events, "command_finished"); + assert_eq!(finished["results_complete"], serde_json::json!(false)); +} + +#[test] +fn test_single_missing_xml_fails_when_bazel_passed() { + let harness = Harness::new("test-partial-missing"); + let raw = (0..4) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("test.xml"), + write_bep_artifact( + &harness, + &format!("strict-ok-{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + lines.push(test_result_line( + "//a:missing", + &[(String::from("test.xml"), missing_uri("strict-missing.xml"))], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 4, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + let missing = outcomes + .iter() + .find(|outcome| outcome["target"] == serde_json::json!("//a:missing")) + .expect("missing target outcome"); + assert_eq!(missing["evidence_complete"], serde_json::json!(false)); + assert_eq!(missing["artifacts"]["missing"], serde_json::json!(1)); + let incomplete: Vec<_> = events_of_kind(&events, "error") + .into_iter() + .filter(|error| error["code"] == serde_json::json!("incomplete_results")) + .collect(); + assert_eq!(incomplete.len(), 1, "{out}"); + let finished = event(&events, "command_finished"); + assert_eq!(finished["exit_code"], serde_json::json!(1)); + assert_eq!(finished["results_complete"], serde_json::json!(false)); +} + +#[test] +fn test_missing_xml_reports_text_failure() { + let harness = Harness::new("test-strict-text"); + let raw = (0..4) + .map(|index| { + test_result_line( + &format!("//a:ok{index}"), + &[( + String::from("test.xml"), + write_bep_artifact( + &harness, + &format!("strict-text-ok-{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ), + )], + ) + }) + .collect::>(); + let mut lines = raw; + lines.push(test_result_line( + "//a:missing", + &[( + String::from("test.xml"), + missing_uri("strict-text-missing.xml"), + )], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn test_complete_run_passes() { + let harness = Harness::new("test-strict-complete"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("incomplete_results"), "{err}"); + let document = std::fs::read(harness.workspace.join("out.xml")).expect("junit"); + let text = String::from_utf8(document).expect("utf8"); + assert!(text.contains(" = events_of_kind(&events, "error") + .into_iter() + .filter(|error| error["code"] == serde_json::json!("bazel_failed")) + .collect(); + assert_eq!(failed.len(), 1, "{out}"); + assert_eq!(failed[0]["cause"], serde_json::json!("bazel"), "{out}"); + assert_eq!(failed[0]["status"], serde_json::json!(4), "{out}"); + assert_eq!(failed[0]["phase"], serde_json::json!("execute"), "{out}"); + let finished = event(&events, "command_finished"); + assert_eq!(finished["exit_code"], serde_json::json!(4)); + assert_eq!(finished["results_complete"], serde_json::json!(false)); +} + +#[test] +fn test_label_pattern_without_tests_reports_no_tests() { + let harness = Harness { + bazel_code: 4, + ..Harness::new("test-no-tests-pattern") + }; + let (code, out, err) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 4, "{out}{err}"); + let events = json_events(&out); + assert!( + errors_with_code(&events, "bazel_failed").is_empty(), + "{out}" + ); + let found = errors_with_code(&events, "no_tests"); + assert_eq!(found.len(), 1, "{out}"); + assert_eq!(found[0]["status"], serde_json::json!(4), "{out}"); + assert!( + err.contains("next: check the target or path scope and run the command again."), + "{err}" + ); + assert_eq!( + event(&events, "command_finished")["exit_code"], + serde_json::json!(4) + ); +} + +fn failing_run(name: &str, statuses: &[&str]) -> (i32, String, String) { + let harness = Harness::new(name); + let lines = statuses + .iter() + .enumerate() + .map(|(index, status)| { + let uri = write_bep_artifact( + &harness, + &format!("{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ); + test_result_full_line( + &format!("//a:t{index}"), + 1, + 1, + 1, + status, + None, + None, + None, + &[(String::from("test.xml"), uri)], + ) + }) + .collect(); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(lines), + ..harness + }; + harness.run(&["test", "--output=json"]) +} + +fn errors_with_code<'a>(events: &'a [serde_json::Value], code: &str) -> Vec<&'a serde_json::Value> { + events_of_kind(events, "error") + .into_iter() + .filter(|error| error["code"] == serde_json::json!(code)) + .collect() +} + +#[test] +fn failing_tests_emit_tests_failed_with_counts() { + let (code, out, err) = failing_run("test-tests-failed", &["PASSED", "FAILED"]); + assert_eq!(code, 3, "{out}{err}"); + let events = json_events(&out); + let errors = events_of_kind(&events, "error"); + assert_eq!(errors.len(), 1, "{out}"); + assert_eq!( + errors[0]["code"], + serde_json::json!("tests_failed"), + "{out}" + ); + assert_eq!(errors[0]["cause"], serde_json::json!("test"), "{out}"); + assert_eq!(errors[0]["status"], serde_json::json!(3), "{out}"); + assert_eq!(errors[0]["phase"], serde_json::json!("execute"), "{out}"); + assert_eq!( + errors[0]["message"], + serde_json::json!("1 test failed; read the test_outcome events"), + "{out}" + ); + assert!( + errors_with_code(&events, "bazel_failed").is_empty(), + "{out}" + ); + assert!(!out.contains("dx status"), "{out}"); + assert!(err.is_empty(), "{err}"); + let finished = event(&events, "command_finished"); + assert_eq!( + finished["tests"], + serde_json::json!({"passed": 1, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + assert_eq!(finished["exit_code"], serde_json::json!(3)); +} + +fn attempts_run( + name: &str, + attempts: &[(&str, u32, &str)], + flaky: &[&str], +) -> (i32, String, String) { + let harness = Harness::new(name); + let mut lines: Vec = attempts + .iter() + .enumerate() + .map(|(index, (label, attempt, status))| { + let uri = write_bep_artifact( + &harness, + &format!("{index}.xml"), + MINIMAL_TEST_XML.as_bytes(), + ); + test_result_full_line( + label, + 1, + 1, + *attempt, + status, + None, + Some(false), + None, + &[(String::from("test.xml"), uri)], + ) + }) + .collect(); + lines.extend(flaky.iter().map(|label| test_flaky_summary_line(label))); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(lines), + ..harness + }; + harness.run(&["test", "--output=json"]) +} + +#[test] +fn flaky_target_with_real_failure_counts_one_failure() { + let (code, out, err) = attempts_run( + "test-flaky-and-failed", + &[ + ("//a:t", 1, "FAILED"), + ("//a:t", 2, "PASSED"), + ("//b:t", 1, "FAILED"), + ], + &["//a:t"], + ); + assert_eq!(code, 3, "{out}{err}"); + let events = json_events(&out); + let errors = errors_with_code(&events, "tests_failed"); + assert_eq!(errors.len(), 1, "{out}"); + assert_eq!( + errors[0]["message"], + serde_json::json!("1 test failed; read the test_outcome events"), + "{out}" + ); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 3, "{out}"); + let finals: Vec<(String, u64, Option)> = outcomes + .iter() + .map(|outcome| { + ( + outcome["target"].as_str().expect("target").to_owned(), + outcome["attempt"].as_u64().expect("attempt"), + outcome.get("final").cloned(), + ) + }) + .collect(); + assert!( + finals.contains(&("//a:t".to_owned(), 1, Some(serde_json::json!(false)))), + "{out}" + ); + assert!(finals.contains(&("//a:t".to_owned(), 2, None)), "{out}"); + assert!(finals.contains(&("//b:t".to_owned(), 1, None)), "{out}"); + assert_eq!(final_failures(&outcomes), 1, "{out}"); + assert!(err.is_empty(), "{err}"); +} + +fn final_failures(outcomes: &[&serde_json::Value]) -> usize { + outcomes + .iter() + .filter(|outcome| outcome["outcome"] == "failed" && outcome.get("final").is_none()) + .count() +} + +#[test] +fn retried_failure_counts_once() { + let (code, out, err) = attempts_run( + "test-retried-failure", + &[ + ("//a:t", 1, "FAILED"), + ("//a:t", 2, "FAILED"), + ("//a:t", 3, "FAILED"), + ], + &[], + ); + assert_eq!(code, 3, "{out}{err}"); + let events = json_events(&out); + let errors = errors_with_code(&events, "tests_failed"); + assert_eq!(errors.len(), 1, "{out}"); + assert_eq!( + errors[0]["message"], + serde_json::json!("1 test failed; read the test_outcome events"), + "{out}" + ); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 0, "failed": 1, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 3, "{out}"); + for outcome in &outcomes { + let expected = if outcome["attempt"] == 3 { + None + } else { + Some(&serde_json::json!(false)) + }; + assert_eq!(outcome.get("final"), expected, "{out}"); + } + assert_eq!(final_failures(&outcomes), 1, "{out}"); +} + +#[test] +fn every_failed_test_still_reports_counts() { + let (code, out, _) = failing_run("test-all-failed", &["FAILED", "TIMEOUT"]); + assert_eq!(code, 3, "{out}"); + let events = json_events(&out); + let failed = errors_with_code(&events, "tests_failed"); + assert_eq!(failed.len(), 1, "{out}"); + assert_eq!( + failed[0]["message"], + serde_json::json!("2 tests failed; read the test_outcome events"), + "{out}" + ); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 0, "failed": 2, "cached": 0, "no_cases": 0, "skipped": 0}) + ); +} + +#[test] +fn exit_three_without_failed_results_keeps_bazel_failed() { + let (code, out, _) = failing_run("test-exit-three-passed", &["PASSED"]); + assert_eq!(code, 3, "{out}"); + let events = json_events(&out); + assert!( + errors_with_code(&events, "tests_failed").is_empty(), + "{out}" + ); + let failed = errors_with_code(&events, "bazel_failed"); + assert_eq!(failed.len(), 1, "{out}"); + assert_eq!(failed[0]["cause"], serde_json::json!("bazel"), "{out}"); + assert_eq!(failed[0]["status"], serde_json::json!(3), "{out}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) + ); +} + +#[test] +fn test_server_crash_reports_server_crashed() { + let harness = Harness::new("test-server-crash"); + let uri = write_bep_artifact(&harness, "0.xml", MINIMAL_TEST_XML.as_bytes()); + let line = test_result_full_line( + "//a:t0", + 1, + 1, + 1, + "PASSED", + None, + None, + None, + &[(String::from("test.xml"), uri)], + ); + let harness = Harness { + bazel_code: 37, + raw_bep: Some(vec![line]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json", "//a:t0"]); + assert_eq!(code, 37, "{out}"); + let events = json_events(&out); + let crashed = errors_with_code(&events, "server_crashed"); + assert_eq!(crashed.len(), 1, "{out}"); + assert_eq!(crashed[0]["cause"], serde_json::json!("host"), "{out}"); + assert!( + errors_with_code(&events, "bazel_failed").is_empty(), + "{out}" + ); +} + +#[test] +fn test_signalled_run_emits_no_test_outcome() { + let mut harness = Harness::new("test-outcome-signalled"); + harness.signalled = true; + let (code, out, err) = harness.run(&["test", "--output=json"]); + assert_ne!(code, 0, "{out}{err}"); + assert!(!out.contains("test_outcome"), "{out}"); +} + +#[test] +fn test_coverage_outcomes_mark_only_final_attempt_evidence() { + let harness = Harness::new("cov-outcome-retry"); + let final_uri = write_bep_artifact(&harness, "final.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_identity_line( + "//a:t", + 1, + 1, + 1, + &[(String::from("test.lcov"), missing_uri("cov-attempt1.dat"))], + ), + test_result_identity_line("//a:t", 1, 1, 2, &[(String::from("test.lcov"), final_uri)]), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["coverage", "--output=json", "--report=results=res.json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + assert!(events_of_kind(&events, "test_outcome").is_empty(), "{out}"); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + let results: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json"); + let entries = results["results"].as_array().expect("results"); + assert_eq!(entries.len(), 1, "{results}"); + assert_eq!(entries[0]["attempt"], serde_json::json!(2)); + assert!(entries[0].get("artifacts").is_none(), "{results}"); + assert!(entries[0].get("final").is_none(), "{results}"); +} + +const MIXED_CASES_XML: &str = r#"tracepanic text"#; + +fn completed_line(label: &str, children: &[serde_json::Value]) -> String { + serde_json::json!({ + "id": {"targetCompleted": {"label": label}}, + "completed": {"success": true}, + "children": children, + }) + .to_string() +} + +fn summary_line(label: &str, status: &str) -> String { + serde_json::json!({ + "id": {"testSummary": {"label": label}}, + "testSummary": {"overallStatus": status}, + }) + .to_string() +} + +#[test] +fn a_result_without_xml_beside_one_with_xml_is_incomplete() { + let harness = Harness::new("test-mixed-xml"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + test_result_line("//a:ok", &[(String::from("test.xml"), uri)]), + test_result_line("//b:bare", &[]), + ]), + ..harness + }; + let (code, out, err) = harness.run(&["test", "--output=text", "--report=junit=out.xml"]); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains( + "dx: incomplete_results: test.xml for //b:bare (run 1, shard 1, attempt 1) was not reported" + ), + "{err}" + ); + let document = std::fs::read_to_string(harness.workspace.join("out.xml")).expect("junit"); + assert!( + document.contains(" = events_of_kind(&events, "error") + .into_iter() + .filter(|error| error["code"] == "incomplete_results") + .collect(); + assert_eq!(incomplete.len(), 1, "{out}"); +} + +#[test] +fn readable_reports_emit_each_case_with_its_identity() { + let harness = Harness::new("test-cases"); + let uri = write_bep_artifact(&harness, "cases.xml", MIXED_CASES_XML.as_bytes()); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", + 1, + 2, + 1, + "FAILED", + Some("cfg1"), + Some(true), + Some(40), + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); + assert_eq!(code, 3, "{out}"); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + assert_eq!(outcome["target"], serde_json::json!("//a:t")); + assert_eq!(outcome["configuration"], serde_json::json!("cfg1")); + assert_eq!(outcome["shard"], serde_json::json!(2)); + assert_eq!(outcome["cached"], serde_json::json!(true)); + assert_eq!(outcome["cases_total"], serde_json::json!(4)); + assert_eq!(outcome["cases_failed"], serde_json::json!(1)); + assert_eq!(outcome["cases_error"], serde_json::json!(1)); + assert_eq!(outcome["cases_skipped"], serde_json::json!(1)); + assert_eq!( + outcome["failing_cases"], + serde_json::json!([ + {"classname": "c", "name": "fails", "outcome": "failed", "message": "expected 1"}, + {"classname": "c", "name": "errs", "outcome": "error", "message": "panic text"}, + ]) + ); + let document: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json"); + let entries = document["results"].as_array().expect("results"); + assert_eq!(entries.len(), 1, "{document}"); + let named: Vec<(&str, &str)> = entries[0]["cases"] + .as_array() + .expect("cases") + .iter() + .map(|case| { + ( + case["name"].as_str().expect("name"), + case["outcome"].as_str().expect("outcome"), + ) + }) + .collect(); + assert_eq!( + named, + vec![ + ("passes", "passed"), + ("fails", "failed"), + ("errs", "error"), + ("ignored", "skipped"), + ] + ); + let reports = events_of_kind(&events, "report"); + assert_eq!(reports[0]["format"], serde_json::json!("results")); + assert_eq!(reports[0]["path"], serde_json::json!("res.json")); +} + +fn failing_result_files(harness: &Harness, with_xml: bool) -> Vec<(String, String)> { + let mut files = vec![( + String::from("test.lcov"), + write_bep_artifact(harness, "coverage.dat", MINIMAL_LCOV.as_bytes()), + )]; + if with_xml { + files.push(( + String::from("test.xml"), + write_bep_artifact(harness, "cases.xml", MIXED_CASES_XML.as_bytes()), + )); + } + files.push(( + String::from("test.log"), + write_bep_artifact(harness, "test.log", b"first\nboom happened\n"), + )); + files.push(( + String::from("test.outputs__outputs.zip"), + write_bep_artifact(harness, "outputs.zip", b"zip"), + )); + files +} + +#[test] +fn test_failing_outcome_carries_log_path_and_tail() { + let harness = Harness::new("test-failing-log"); + let files = failing_result_files(&harness, true); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, + )]), + ..harness + }; + let (_, out, _) = harness.run(&["test", "--output=json"]); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + assert!(outcome["log_path"].as_str().is_some(), "{out}"); + assert!( + outcome["outputs_path"] + .as_str() + .expect("outputs") + .ends_with("outputs.zip"), + "{out}" + ); + assert!( + outcome["log_tail"] + .as_str() + .expect("tail") + .contains("boom happened"), + "{out}" + ); +} + +#[test] +fn test_text_mode_names_failing_cases_and_log() { + let harness = Harness::new("test-failing-text"); + let files = failing_result_files(&harness, true); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, + )]), + ..harness + }; + let (_, _, err) = harness.run(&["test", "--output=text"]); + let line = err + .lines() + .find(|line| line.starts_with("dx: failed: //a:t: fails, errs")) + .unwrap_or_else(|| panic!("{err}")); + assert!( + line.contains("(log: ") && line.contains("test.log)"), + "{line}" + ); +} + +#[test] +fn coverage_failing_outcome_carries_failing_cases_and_log() { + let harness = Harness::new("cov-failing-cases"); + let files = failing_result_files(&harness, true); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, + )]), + ..harness + }; + let (_, out, _) = harness.run(&["coverage", "--output=json"]); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + let failing = outcome["failing_cases"].as_array().expect("failing"); + let names: Vec<&str> = failing + .iter() + .map(|case| case["name"].as_str().expect("name")) + .collect(); + assert_eq!(names, vec!["fails", "errs"], "{out}"); + assert!(outcome["log_path"].as_str().is_some(), "{out}"); + assert!( + outcome["log_tail"] + .as_str() + .expect("tail") + .contains("boom happened"), + "{out}" + ); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(true), + "{out}" + ); +} + +#[test] +fn coverage_failing_outcome_without_test_xml_stays_complete() { + let harness = Harness::new("cov-failing-no-xml"); + let files = failing_result_files(&harness, false); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", 1, 1, 1, "FAILED", None, None, None, &files, + )]), + ..harness + }; + let (_, out, _) = harness.run(&["coverage", "--output=json"]); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + assert!(outcome.get("failing_cases").is_none(), "{out}"); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(true), + "{out}" + ); +} + +#[test] +fn passed_count_is_per_run_and_shard() { + let harness = Harness::new("test-count-unit"); + let mut lines = Vec::new(); + let xml = vec![( + String::from("test.xml"), + write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()), + )]; + for run in 1..=2 { + for shard in 1..=2 { + let attempt_two = run == 1 && shard == 1; + if attempt_two { + lines.push(test_result_full_line( + "//a:t", run, shard, 1, "FAILED", None, None, None, &xml, + )); + lines.push(test_result_full_line( + "//a:t", run, shard, 2, "PASSED", None, None, None, &xml, + )); + } else { + lines.push(test_result_identity_line("//a:t", run, shard, 1, &xml)); + } + } + } + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (_, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); + let events = json_events(&out); + let tests = &event(&events, "command_finished")["tests"]; + assert_eq!(tests["passed"], serde_json::json!(4), "{out}"); + assert_eq!(tests["failed"], serde_json::json!(0), "{out}"); + let results: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json"); + let passed = results["results"] + .as_array() + .expect("results") + .iter() + .filter(|entry| entry["bazel_status"] == "PASSED" && entry["final"] != false) + .count(); + assert_eq!(passed, 4, "{results}"); +} + +#[test] +fn failing_case_past_the_case_limit_is_still_reported() { + let harness = Harness::new("test-late-failure"); + let mut xml = String::from(""); + for index in 0..1500 { + xml.push_str(&format!("")); + } + xml.push_str(""); + xml.push_str(""); + let uri = write_bep_artifact(&harness, "late.xml", xml.as_bytes()); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + None, + None, + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (_, out, _) = harness.run(&["test", "--output=json"]); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + let failing = outcome["failing_cases"].as_array().expect("failing"); + assert_eq!(failing.len(), 1, "{out}"); + assert_eq!(failing[0]["name"], serde_json::json!("late")); +} + +#[test] +fn default_results_report_failure_fails_the_run() { + let harness = Harness::new("test-results-default-blocked"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + std::fs::create_dir_all(harness.workspace.join(".dx/local/results.json")).expect("block"); + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + assert!( + events.iter().any(|event| event["code"] == "report_failed"), + "{out}" + ); + let finished = events.last().expect("command_finished"); + assert_eq!(finished["event"], "command_finished", "{out}"); + assert_eq!(finished["results_complete"], false, "{out}"); +} + +#[test] +fn default_results_report_failure_keeps_the_bazel_code() { + let harness = Harness::new("test-results-default-blocked-bazel"); + let uri = write_bep_artifact(&harness, "bad.xml", MIXED_TEST_XML.as_bytes()); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + None, + None, + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + std::fs::create_dir_all(harness.workspace.join(".dx/local/results.json")).expect("block"); + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 3, "{out}"); + let events = json_events(&out); + let finished = events.last().expect("command_finished"); + assert_eq!(finished["results_complete"], false, "{out}"); +} + +#[test] +fn results_document_run_id_matches_command_finished() { + let harness = Harness::new("test-results-run-id"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let finished = events.last().expect("command_finished"); + let run_id = finished["run_id"].as_str().expect("run_id in event"); + let file = std::fs::read_to_string(harness.workspace.join(".dx/local/results.json")) + .expect("results file"); + let document: serde_json::Value = serde_json::from_str(&file).expect("json"); + assert_eq!(document["run_id"], run_id); + assert_eq!(document["record_version"], 1); +} + +#[test] +fn explicit_results_report_carries_the_run_id() { + let harness = Harness::new("test-results-run-id-explicit"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, out, _) = harness.run(&[ + "test", + "--output=json", + "--report", + "results=elsewhere.json", + ]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let finished = events.last().expect("command_finished"); + let run_id = finished["run_id"].as_str().expect("run_id in event"); + let file = + std::fs::read_to_string(harness.workspace.join("elsewhere.json")).expect("results file"); + let document: serde_json::Value = serde_json::from_str(&file).expect("json"); + assert_eq!(document["run_id"], run_id); + assert!(!harness.workspace.join(".dx/local/results.json").exists()); +} + +#[test] +fn case_detail_is_bounded_and_says_so() { + let harness = Harness::new("test-case-bounds"); + let long = "é".repeat(3000); + let mut xml = String::from(""); + xml.push_str(&format!( + "" + )); + for index in 0..1000 { + xml.push_str(&format!("")); + } + xml.push_str(""); + let uri = write_bep_artifact(&harness, "many.xml", xml.as_bytes()); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + None, + None, + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (_, out, _) = harness.run(&["test", "--output=json", "--report=results=res.json"]); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + assert_eq!(outcome["cases_total"], serde_json::json!(1001)); + let failing = outcome["failing_cases"].as_array().expect("failing"); + assert_eq!(failing.len(), 1); + let message = failing[0]["message"].as_str().expect("message"); + assert_eq!(message.len(), dx_output::EVENT_MESSAGE_LIMIT); + assert!(message.chars().all(|c| c == 'é')); + assert!(outcome.get("failing_cases_omitted").is_none(), "{out}"); + let document: serde_json::Value = serde_json::from_slice( + &std::fs::read(harness.workspace.join("res.json")).expect("results report"), + ) + .expect("results json"); + let cases = document["results"][0]["cases"].as_array().expect("cases"); + assert_eq!(cases.len(), 1000); + assert_eq!( + document["results"][0]["cases_omitted"], + serde_json::json!(1) + ); + assert_eq!(cases[0]["message"].as_str().expect("message").len(), 4096); +} + +#[test] +fn an_announced_result_that_never_arrives_is_incomplete() { + let harness = Harness::new("test-gap"); + let uri = write_bep_artifact(&harness, "shard1.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + completed_line( + "//a:t", + &[ + serde_json::json!({"testResult": {"label": "//a:t", "run": 1, "shard": 1, "attempt": 1}}), + serde_json::json!({"testResult": {"label": "//a:t", "run": 1, "shard": 2, "attempt": 1}}), + ], + ), + test_result_line("//a:t", &[(String::from("test.xml"), uri)]), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("not_reported")); + assert_eq!(outcomes[0]["shard"], serde_json::json!(2)); + assert_eq!(outcomes[0]["evidence_complete"], serde_json::json!(false)); + assert!(outcomes[0].get("bazel_status").is_none(), "{out}"); + assert!(outcomes[0].get("cases_total").is_none(), "{out}"); + let error = events_of_kind(&events, "error") + .into_iter() + .find(|error| error["code"] == "incomplete_results") + .expect("incomplete"); + assert_eq!( + error["message"], + serde_json::json!( + "testResult for //a:t (run 1, shard 2, attempt 1) was announced but never reported" + ) + ); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(false) + ); +} + +#[test] +fn a_cancelled_result_keeps_the_bazel_code_and_its_cause() { + let harness = Harness { + bazel_code: 8, + raw_bep: Some(vec![ + serde_json::json!({ + "id": {"testResult": {"label": "//a:t", "run": 1, "shard": 1, "attempt": 1}}, + "aborted": {"reason": "USER_INTERRUPTED"}, + }) + .to_string(), + ]), + ..Harness::new("test-cancelled") + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 8, "{out}"); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + assert_eq!(outcome["outcome"], serde_json::json!("cancelled")); + assert_eq!(outcome["evidence_complete"], serde_json::json!(false)); + assert_eq!( + event(&events, "command_finished")["exit_code"], + serde_json::json!(8) + ); +} + +#[test] +fn a_stream_without_bazels_last_event_is_incomplete() { + let harness = Harness::new("test-unterminated"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + unterminated_bep: true, + raw_bep: Some(vec![test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )]), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("dx: incomplete_results: build events ended before Bazel's last event"), + "{err}" + ); + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + assert!( + events_of_kind(&events, "test_outcome").is_empty(), + "the collected result itself stays complete and is counted: {out}" + ); + assert_eq!( + event(&events, "command_finished")["tests"], + serde_json::json!({"passed": 1, "failed": 0, "cached": 0, "no_cases": 0, "skipped": 0}) + ); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(false) + ); +} + +#[test] +fn a_summary_without_results_is_its_own_outcome_without_cases() { + let harness = Harness { + bazel_code: 1, + raw_bep: Some(vec![summary_line("//a:broken", "FAILED_TO_BUILD")]), + ..Harness::new("test-summary-only") + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let outcome = event(&events, "test_outcome"); + assert_eq!(outcome["target"], serde_json::json!("//a:broken")); + assert_eq!(outcome["outcome"], serde_json::json!("failed_to_build")); + assert_eq!( + outcome["bazel_status"], + serde_json::json!("FAILED_TO_BUILD") + ); + assert_eq!(outcome["evidence_complete"], serde_json::json!(false)); + for field in ["cases_total", "run", "shard", "attempt", "cached"] { + assert!(outcome.get(field).is_none(), "{field}: {out}"); + } + + let harness = Harness { + raw_bep: Some(vec![summary_line("//a:t", "PASSED")]), + ..Harness::new("test-summary-only-pass") + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 1, "a summary is not a collected result: {err}"); + assert!( + err.contains("no test result was reported for //a:t (summary PASSED)"), + "{err}" + ); +} + +#[test] +fn a_test_that_fails_to_build_is_its_own_outcome() { + let configured = |label: &str| { + serde_json::json!({ + "id": {"targetConfigured": {"label": label}}, + "configured": {"targetKind": "sh_test rule", "testSize": "MEDIUM"}, + }) + .to_string() + }; + let completed = |label: &str, success: bool| { + serde_json::json!({ + "id": {"targetCompleted": {"label": label, "configuration": {"id": "cfg1"}}}, + "completed": {"success": success}, + }) + .to_string() + }; + let harness = Harness::new("test-unbuilt"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + bazel_code: 1, + raw_bep: Some(vec![ + configured("//a:broken"), + configured("//a:t"), + serde_json::json!({ + "id": {"targetCompleted": {"label": "//a:broken", "configuration": {"id": "cfg1"}}}, + "completed": {"outputGroup": [{"name": "default", "fileSets": []}]}, + }) + .to_string(), + completed("//a:t", true), + test_result_line("//a:t", &[(String::from("test.xml"), uri)]), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["target"], serde_json::json!("//a:broken")); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("failed_to_build")); + assert_eq!(outcomes[0]["configuration"], serde_json::json!("cfg1")); + assert_eq!(outcomes[0]["evidence_complete"], serde_json::json!(false)); + for field in [ + "bazel_status", + "cases_total", + "run", + "shard", + "attempt", + "cached", + ] { + assert!(outcomes[0].get(field).is_none(), "{field}: {out}"); + } + let error = events_of_kind(&events, "error") + .into_iter() + .find(|error| error["code"] == "incomplete_results") + .expect("incomplete"); + assert_eq!(error["code"], serde_json::json!("incomplete_results")); + assert_eq!( + error["message"], + serde_json::json!("//a:broken failed to build") + ); + let finished = event(&events, "command_finished"); + assert_eq!(finished["results_complete"], serde_json::json!(false)); + + let harness = Harness { + bazel_code: 1, + raw_bep: Some(vec![ + configured("//a:broken"), + completed("//a:broken", false), + summary_line("//a:broken", "FAILED_TO_BUILD"), + ]), + ..Harness::new("test-unbuilt-summarized") + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!( + outcomes[0]["bazel_status"], + serde_json::json!("FAILED_TO_BUILD") + ); +} + +#[test] +fn skipped_targets_are_reported_without_blocking_collected_results() { + let harness = Harness::new("test-skipped"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let configured = serde_json::json!({ + "id": {"targetConfigured": {"label": "//a:windows_only"}}, + "configured": {"targetKind": "sh_test rule", "testSize": "SMALL"}, + }) + .to_string(); + let skipped = serde_json::json!({ + "id": {"targetCompleted": {"label": "//a:windows_only", "configuration": {"id": "cfg1"}}}, + "aborted": {"reason": "SKIPPED"}, + }) + .to_string(); + let harness = Harness { + raw_bep: Some(vec![ + configured.clone(), + skipped.clone(), + test_result_line("//a:t", &[(String::from("test.xml"), uri)]), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["target"], serde_json::json!("//a:windows_only")); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("skipped")); + assert_eq!(outcomes[0]["configuration"], serde_json::json!("cfg1")); + assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(true) + ); + + let only_skipped = Harness { + raw_bep: Some(vec![configured, skipped]), + ..Harness::new("test-only-skipped") + }; + let (code, out, _) = only_skipped.run(&["test", "--output=json"]); + assert_eq!(code, 0, "nothing was selected to run: {out}"); + let events = json_events(&out); + assert_eq!( + event(&events, "test_outcome")["outcome"], + serde_json::json!("skipped") + ); +} + +fn filtered_target_lines() -> Vec { + vec![ + serde_json::json!({ + "id": {"targetConfigured": {"label": "//a:slow"}}, + "configured": {"targetKind": "sh_test rule", "testSize": "LARGE"}, + }) + .to_string(), + serde_json::json!({ + "id": {"targetCompleted": {"label": "//a:slow", "configuration": {"id": "cfg1"}}}, + "completed": {"success": true}, + }) + .to_string(), + ] +} + +#[test] +fn filtered_targets_are_reported_and_counted() { + let harness = Harness::new("test-filtered"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let mut lines = filtered_target_lines(); + lines.push(test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, out, _) = harness.run(&["test", "--output=json"]); + assert_eq!(code, 0, "{out}"); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert_eq!(outcomes.len(), 1, "{out}"); + assert_eq!(outcomes[0]["target"], serde_json::json!("//a:slow")); + assert_eq!(outcomes[0]["outcome"], serde_json::json!("filtered")); + assert!(outcomes[0].get("evidence_complete").is_none(), "{out}"); + let notices: Vec<_> = events_of_kind(&events, "notice") + .into_iter() + .filter(|notice| notice["code"] == serde_json::json!("tests_filtered")) + .collect(); + assert_eq!(notices.len(), 1, "{out}"); + let finished = event(&events, "command_finished"); + assert_eq!(finished["tests"]["skipped"], serde_json::json!(1), "{out}"); + assert_eq!(finished["tests"]["failed"], serde_json::json!(0), "{out}"); +} + +#[test] +fn skipped_targets_print_a_summary_line_and_keep_the_exit_code() { + let harness = Harness::new("test-skipped-notice"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let lines = vec![ + serde_json::json!({ + "id": {"targetConfigured": {"label": "//a:skip"}}, + "configured": {"targetKind": "sh_test rule", "testSize": "SMALL"}, + }) + .to_string(), + serde_json::json!({ + "id": {"targetCompleted": {"label": "//a:skip", "configuration": {"id": "cfg1"}}}, + "aborted": {"reason": "SKIPPED"}, + }) + .to_string(), + test_result_line("//a:t", &[(String::from("test.xml"), uri)]), + ]; + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert_eq!(err.matches("dx: skipped 1 target ").count(), 1, "{err}"); +} + +#[test] +fn filtered_targets_print_a_stderr_line_in_text_mode() { + let harness = Harness::new("test-filtered-text"); + let uri = write_bep_artifact(&harness, "ok.xml", MINIMAL_TEST_XML.as_bytes()); + let mut lines = filtered_target_lines(); + lines.push(test_result_line( + "//a:t", + &[(String::from("test.xml"), uri)], + )); + let harness = Harness { + raw_bep: Some(lines), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert_eq!(err.matches("dx: tests_filtered: ").count(), 1, "{err}"); + assert!(err.contains("//a:slow"), "{err}"); +} + +#[test] +fn coverage_reports_filtered_targets() { + let mut harness = coverage_harness("cov-filtered", MINIMAL_LCOV.as_bytes()); + let mut lines = filtered_target_lines(); + lines.extend(harness.raw_bep.take().expect("bep")); + harness.raw_bep = Some(lines); + let (_, out, _) = harness.run(&["coverage", "--output=json"]); + let events = json_events(&out); + let outcomes = events_of_kind(&events, "test_outcome"); + assert!( + outcomes + .iter() + .any(|outcome| outcome["outcome"] == serde_json::json!("filtered")), + "{out}" + ); +} + +#[test] +fn coverage_without_bazels_last_event_is_incomplete() { + let mut harness = coverage_harness("cov-unterminated", MINIMAL_LCOV.as_bytes()); + harness.unterminated_bep = true; + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("dx: incomplete_results: build events ended before Bazel's last event"), + "{err}" + ); +} + +fn no_collector_configured(label: &str) -> String { + serde_json::json!({ + "id": {"targetConfigured": {"label": label}}, + "configured": { + "targetKind": "_zig_forward_test rule", + "testSize": "SMALL", + "tag": ["manual", "dx-no-coverage-collector"], + }, + }) + .to_string() +} + +#[test] +fn coverage_fails_when_only_collectorless_tests_ran() { + let harness = Harness::new("cov-no-collector-only"); + let empty = write_bep_artifact(&harness, "zig.dat", b""); + let harness = Harness { + raw_bep: Some(vec![ + no_collector_configured("//z:t"), + test_result_line("//z:t", &[(String::from("coverage.dat"), empty)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--fail-on=warning"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("dx: no_coverage_collector: no coverage collector for //z:t"), + "{err}" + ); + assert!(!err.contains("incomplete_results"), "{err}"); +} + +#[test] +fn coverage_fails_when_collectorless_tests_mix_with_lcov() { + let harness = Harness::new("cov-no-collector-mixed"); + let empty = write_bep_artifact(&harness, "zig2.dat", b""); + let ok = write_bep_artifact(&harness, "rust.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + no_collector_configured("//z:t"), + test_result_line("//z:t", &[(String::from("coverage.dat"), empty.clone())]), + test_result_line("//z:t", &[(String::from("coverage.dat"), empty)]), + test_result_line("//r:t", &[(String::from("coverage.dat"), ok)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + "--fail-on=warning", + "--report=lcov=out.lcov", + ]); + assert_eq!(code, 1, "{err}"); + assert_eq!(err.matches("no_coverage_collector").count(), 1, "{err}"); + let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); + assert_eq!(document, MINIMAL_LCOV.as_bytes()); +} + +#[test] +fn coverage_skips_collectorless_tests_with_one_line_per_language() { + let harness = Harness::new("cov-no-collector-skip"); + let empty = write_bep_artifact(&harness, "zig4.dat", b""); + let ok = write_bep_artifact(&harness, "rust2.dat", MINIMAL_LCOV.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + no_collector_configured("//z:a"), + no_collector_configured("//z:b"), + test_result_line("//z:a", &[(String::from("coverage.dat"), empty.clone())]), + test_result_line("//z:b", &[(String::from("coverage.dat"), empty)]), + test_result_line("//r:t", &[(String::from("coverage.dat"), ok)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["coverage", "--output=text", "--fail-on=error"]); + assert_eq!(code, 0, "{err}"); + assert_eq!( + err.matches("dx: skipped 2 zig tests: no coverage collector (#7298)") + .count(), + 1, + "{err}" + ); + assert!(!err.contains("dx: no_coverage_collector"), "{err}"); +} + +#[test] +fn coverage_ignores_collectorless_tests_without_results() { + let mut harness = coverage_harness("cov-no-collector-filtered", MINIMAL_LCOV.as_bytes()); + let mut lines = vec![no_collector_configured("//z:skipped")]; + lines.extend(harness.raw_bep.take().expect("bep")); + harness.raw_bep = Some(lines); + let (code, _, err) = harness.run(&["coverage", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("no_coverage_collector"), "{err}"); +} + +#[test] +fn coverage_no_collector_is_a_json_error_event() { + let harness = Harness::new("cov-no-collector-json"); + let empty = write_bep_artifact(&harness, "zig3.dat", b""); + let harness = Harness { + raw_bep: Some(vec![ + no_collector_configured("//z:t"), + test_result_line("//z:t", &[(String::from("coverage.dat"), empty)]), + ]), + ..harness + }; + let (code, out, _) = harness.run(&["coverage", "--output=json", "--fail-on=warning"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + assert!( + events_of_kind(&events, "error") + .iter() + .any(|event| event["code"] == serde_json::json!("no_coverage_collector")), + "{out}" + ); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(true), + "{out}" + ); +} + +#[test] +fn coverage_reports_kcov_lcov_from_untagged_zig_tests() { + let harness = Harness::new("cov-zig-kcov"); + let kcov = "SF:zig/calc.zig\nDA:1,2\nDA:6,0\nLF:2\nLH:1\nend_of_record\n"; + let uri = write_bep_artifact(&harness, "kcov.dat", kcov.as_bytes()); + let configured = serde_json::json!({ + "id": {"targetConfigured": {"label": "//z:t"}}, + "configured": {"targetKind": "_zig_forward_test rule", "testSize": "SMALL", "tag": []}, + }) + .to_string(); + let harness = Harness { + raw_bep: Some(vec![ + configured, + test_result_line("//z:t", &[(String::from("coverage.dat"), uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + "--fail-on=warning", + "--report=lcov=out.lcov", + ]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("no coverage collector"), "{err}"); + let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); + assert_eq!(document, kcov.as_bytes()); +} + +#[test] +fn coverage_reports_kcov_lcov_from_untagged_fortran_tests() { + let harness = Harness::new("cov-fortran-kcov"); + let kcov = "SF:fortran/calc.f90\nDA:1,2\nDA:6,0\nLF:2\nLH:1\nend_of_record\n"; + let uri = write_bep_artifact(&harness, "kcov.dat", kcov.as_bytes()); + let configured = serde_json::json!({ + "id": {"targetConfigured": {"label": "//f:t"}}, + "configured": {"targetKind": "_fortran_forward_test rule", "testSize": "SMALL", "tag": []}, + }) + .to_string(); + let harness = Harness { + raw_bep: Some(vec![ + configured, + test_result_line("//f:t", &[(String::from("coverage.dat"), uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + "--fail-on=warning", + "--report=lcov=out.lcov", + ]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("no coverage collector"), "{err}"); + let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); + assert_eq!(document, kcov.as_bytes()); +} + +#[test] +fn coverage_reports_bisect_lcov_from_untagged_ocaml_tests() { + let harness = Harness::new("cov-ocaml-bisect"); + let lcov = "SF:ocaml/calc.ml\nDA:1,2\nDA:6,0\nLF:2\nLH:1\nend_of_record\n"; + let uri = write_bep_artifact(&harness, "bisect.dat", lcov.as_bytes()); + let configured = serde_json::json!({ + "id": {"targetConfigured": {"label": "//o:t"}}, + "configured": {"targetKind": "_ocaml_forward_test rule", "testSize": "SMALL", "tag": []}, + }) + .to_string(); + let harness = Harness { + raw_bep: Some(vec![ + configured, + test_result_line("//o:t", &[(String::from("coverage.dat"), uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&[ + "coverage", + "--output=text", + "--fail-on=warning", + "--report=lcov=out.lcov", + ]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("no coverage collector"), "{err}"); + let document = std::fs::read(harness.workspace.join("out.lcov")).expect("lcov"); + assert_eq!(document, lcov.as_bytes()); +} + +#[test] +fn test_ignores_the_no_collector_tag() { + let harness = Harness::new("test-no-collector"); + let uri = write_bep_artifact(&harness, "zig.xml", MINIMAL_TEST_XML.as_bytes()); + let harness = Harness { + raw_bep: Some(vec![ + no_collector_configured("//z:t"), + test_result_line("//z:t", &[(String::from("test.xml"), uri)]), + ]), + ..harness + }; + let (code, _, err) = harness.run(&["test", "--output=text"]); + assert_eq!(code, 0, "{err}"); + assert!(!err.contains("no_coverage_collector"), "{err}"); +} + +struct BrokenPipe; + +impl std::io::Write for BrokenPipe { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "broken pipe", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "broken pipe", + )) + } +} + +#[test] +fn announce_incomplete_returns_the_stdout_code_when_stdout_breaks() { + let mut err = Vec::new(); + assert_eq!( + super::announce_incomplete( + dx_output::OutputMode::Json, + &mut BrokenPipe, + &mut err, + "no test.xml" + ), + Err(dx_process::broken_pipe_code()) + ); +} + +#[test] +fn coverage_threshold_returns_the_stdout_code_when_stdout_breaks() { + let mut err = Vec::new(); + let account = dx_lcov::CellAccount::default(); + assert_eq!( + super::coverage_threshold( + Some(80), + Some(&account), + &super::Sources::default(), + dx_output::OutputMode::Json, + &mut BrokenPipe, + &mut err, + ), + Err(dx_process::broken_pipe_code()) + ); +} + +#[test] +fn an_outcome_that_fails_to_build_ends_the_stream_once() { + let outcome = dx_output::TestOutcome { + outcome: "failed".to_owned(), + ..dx_output::TestOutcome::default() + }; + let mut out = Vec::new(); + let mut err = Vec::new(); + let ended = super::emit_outcomes( + dx_output::OutputMode::Json, + &mut out, + &mut err, + &[outcome], + &[], + ); + assert_eq!(ended.err(), Some(dx_process::operational_code())); + let events: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let kinds: Vec<&str> = events + .iter() + .map(|event| event["event"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, ["error", "command_finished"], "{events:?}"); + assert_eq!(events[0]["code"], "invalid_result"); +} From 1759b9c171ca4cfc93be08119c4585a392cf5fb2 Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Mon, 9 Nov 2026 17:09:16 +0100 Subject: [PATCH 2/5] dx: move umbrella, update_consumer, update_version, common, audit tests to sibling files (part of #10053) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hwESun1HL2eSVd1d5B --- cli/cli/BUILD.bazel | 5 + cli/cli/src/exec/audit.rs | 115 +- cli/cli/src/exec/audit_tests.rs | 111 + cli/cli/src/exec/common.rs | 1218 +------- cli/cli/src/exec/common_tests.rs | 1212 ++++++++ cli/cli/src/exec/umbrella.rs | 3456 +-------------------- cli/cli/src/exec/umbrella_tests.rs | 3418 ++++++++++++++++++++ cli/cli/src/exec/update_consumer.rs | 2449 +-------------- cli/cli/src/exec/update_consumer_tests.rs | 2437 +++++++++++++++ cli/cli/src/exec/update_version.rs | 1868 +---------- cli/cli/src/exec/update_version_tests.rs | 1849 +++++++++++ 11 files changed, 9042 insertions(+), 9096 deletions(-) create mode 100644 cli/cli/src/exec/audit_tests.rs create mode 100644 cli/cli/src/exec/common_tests.rs create mode 100644 cli/cli/src/exec/umbrella_tests.rs create mode 100644 cli/cli/src/exec/update_consumer_tests.rs create mode 100644 cli/cli/src/exec/update_version_tests.rs diff --git a/cli/cli/BUILD.bazel b/cli/cli/BUILD.bazel index 1d76848e92..7348fe90f1 100644 --- a/cli/cli/BUILD.bazel +++ b/cli/cli/BUILD.bazel @@ -27,6 +27,7 @@ rust_library( "src/exec.rs", "src/exec/affected.rs", "src/exec/audit.rs", + "src/exec/audit_tests.rs", "src/exec/audit_accept.rs", "src/exec/audit_cyclonedx.rs", "src/exec/audit_images.rs", @@ -41,6 +42,7 @@ rust_library( "src/exec/changed_coverage.rs", "src/exec/clean.rs", "src/exec/common.rs", + "src/exec/common_tests.rs", "src/exec/compiler_stderr.rs", "src/exec/coverage_candidate.rs", "src/exec/dep_edit.rs", @@ -81,15 +83,18 @@ rust_library( "src/exec/test_filter.rs", "src/exec/test_reports.rs", "src/exec/umbrella.rs", + "src/exec/umbrella_tests.rs", "src/exec/update.rs", "src/exec/update_add_missing.rs", "src/exec/update_advisory.rs", "src/exec/update_consumer.rs", + "src/exec/update_consumer_tests.rs", "src/exec/update_outdated.rs", "src/exec/update_pr.rs", "src/exec/update_prune.rs", "src/exec/update_unused.rs", "src/exec/update_version.rs", + "src/exec/update_version_tests.rs", "src/exec/update_version_add.rs", "src/exec/workflow.rs", "src/exec/worktree.rs", diff --git a/cli/cli/src/exec/audit.rs b/cli/cli/src/exec/audit.rs index 33f03e09ae..43ed8a5121 100644 --- a/cli/cli/src/exec/audit.rs +++ b/cli/cli/src/exec/audit.rs @@ -28,119 +28,8 @@ fn today_utc_from(pinned: Option) -> String { } #[cfg(test)] -mod tests { - use super::{ - AUDIT_TODAY_ENV, QueryBazel, parse_locked_for_configured, today_utc_from, uncovered_message, - }; - use dx_adopt::dependency_sets::{Ecosystem, ResolvedSet}; - use dx_plan::resolve::{QueryResult, QueryRunner}; - use dx_tool_resolve::Bazel as _; - use std::cell::RefCell; - use std::path::Path; - - fn unreadable_lock_error(ecosystem: Ecosystem, lock: &str) -> String { - let set = ResolvedSet { - name: "deps".to_owned(), - ecosystem, - dir: String::new(), - manifests: Vec::new(), - locks: vec![lock.to_owned()], - repository: None, - }; - let locks = vec![(lock.to_owned(), "{}".to_owned())]; - parse_locked_for_configured(&set, &locks) - .err() - .map(|error| error.to_string()) - .unwrap_or_default() - } - - #[test] - fn uncovered_message_names_the_set_and_ecosystem() { - let uv = uncovered_message("tools", "uv"); - assert!(uv.contains("tools") && uv.contains("(uv)"), "{uv}"); - assert!(!uv.contains("PowerShell"), "{uv}"); - let shell = uncovered_message("powershell", "powershell"); - assert_eq!( - shell, - "failed to assess powershell: no advisory backend for PowerShell Gallery modules; NuGet.org advisories do not apply; accept it with an [[uncovered]] entry in security.toml" - ); - } - - #[test] - fn uncovered_message_names_luarocks() { - assert_eq!( - uncovered_message("rocks", "luarocks"), - "failed to assess rocks: no advisory backend for LuaRocks rocks; OSV defines no LuaRocks ecosystem (https://ossf.github.io/osv-schema/); accept it with an [[uncovered]] entry in security.toml" - ); - } - - #[test] - fn set_without_a_readable_lock_fails() { - let cargo = unreadable_lock_error(Ecosystem::Cargo, "cargo-bazel-lock.json"); - assert!( - cargo.contains("deps") && cargo.contains("Cargo.lock"), - "{cargo}" - ); - let nuget = unreadable_lock_error(Ecosystem::Nuget, "other.lock"); - assert!(nuget.contains("paket.lock"), "{nuget}"); - let maven = unreadable_lock_error(Ecosystem::Maven, "pins.txt"); - assert!(maven.contains(".json"), "{maven}"); - } - - struct Recorder(RefCell>>); - - impl QueryRunner for Recorder { - fn run_query(&self, argv: &[String], _cwd: &Path) -> std::io::Result { - self.0.borrow_mut().push(argv.to_vec()); - Ok(QueryResult { - code: Some(0), - stdout: Vec::new(), - stderr: Vec::new(), - }) - } - } - - #[test] - fn the_tool_cquery_carries_the_shared_settings_and_ends_with_the_label() { - let recorder = Recorder(RefCell::new(Vec::new())); - let bazel = QueryBazel { - query_runner: &recorder, - workspace: Path::new("."), - startup: &[], - }; - bazel.cquery_files("//t:tool", &[]).expect("cquery"); - let calls = recorder.0.borrow(); - let argv = &calls[0]; - for setting in dx_plan::plan::shared_settings("cquery") { - assert!(argv.contains(&setting), "{setting}"); - } - assert_eq!(argv.last().map(String::as_str), Some("//t:tool")); - } - - #[test] - fn a_pinned_date_makes_the_audit_date_reproducible() { - assert_eq!(today_utc_from(Some("2020-01-02".to_owned())), "2020-01-02"); - assert_eq!(AUDIT_TODAY_ENV, "DX_AUDIT_TODAY"); - } - - #[test] - fn an_unusable_pin_falls_back_to_today() { - for bad in [ - "", - "2020-1-2", - "2020-01-02T00:00", - "not-a-date", - "2020/01/02x", - ] { - let today = today_utc_from(None); - assert_eq!( - today_utc_from(Some(bad.to_owned())), - today, - "{bad:?} is not a pinned date" - ); - } - } -} +#[path = "audit_tests.rs"] +mod tests; #[derive(Debug, thiserror::Error)] pub(crate) enum AuditError { diff --git a/cli/cli/src/exec/audit_tests.rs b/cli/cli/src/exec/audit_tests.rs new file mode 100644 index 0000000000..c1e3735a58 --- /dev/null +++ b/cli/cli/src/exec/audit_tests.rs @@ -0,0 +1,111 @@ +use super::{ + AUDIT_TODAY_ENV, QueryBazel, parse_locked_for_configured, today_utc_from, uncovered_message, +}; +use dx_adopt::dependency_sets::{Ecosystem, ResolvedSet}; +use dx_plan::resolve::{QueryResult, QueryRunner}; +use dx_tool_resolve::Bazel as _; +use std::cell::RefCell; +use std::path::Path; + +fn unreadable_lock_error(ecosystem: Ecosystem, lock: &str) -> String { + let set = ResolvedSet { + name: "deps".to_owned(), + ecosystem, + dir: String::new(), + manifests: Vec::new(), + locks: vec![lock.to_owned()], + repository: None, + }; + let locks = vec![(lock.to_owned(), "{}".to_owned())]; + parse_locked_for_configured(&set, &locks) + .err() + .map(|error| error.to_string()) + .unwrap_or_default() +} + +#[test] +fn uncovered_message_names_the_set_and_ecosystem() { + let uv = uncovered_message("tools", "uv"); + assert!(uv.contains("tools") && uv.contains("(uv)"), "{uv}"); + assert!(!uv.contains("PowerShell"), "{uv}"); + let shell = uncovered_message("powershell", "powershell"); + assert_eq!( + shell, + "failed to assess powershell: no advisory backend for PowerShell Gallery modules; NuGet.org advisories do not apply; accept it with an [[uncovered]] entry in security.toml" + ); +} + +#[test] +fn uncovered_message_names_luarocks() { + assert_eq!( + uncovered_message("rocks", "luarocks"), + "failed to assess rocks: no advisory backend for LuaRocks rocks; OSV defines no LuaRocks ecosystem (https://ossf.github.io/osv-schema/); accept it with an [[uncovered]] entry in security.toml" + ); +} + +#[test] +fn set_without_a_readable_lock_fails() { + let cargo = unreadable_lock_error(Ecosystem::Cargo, "cargo-bazel-lock.json"); + assert!( + cargo.contains("deps") && cargo.contains("Cargo.lock"), + "{cargo}" + ); + let nuget = unreadable_lock_error(Ecosystem::Nuget, "other.lock"); + assert!(nuget.contains("paket.lock"), "{nuget}"); + let maven = unreadable_lock_error(Ecosystem::Maven, "pins.txt"); + assert!(maven.contains(".json"), "{maven}"); +} + +struct Recorder(RefCell>>); + +impl QueryRunner for Recorder { + fn run_query(&self, argv: &[String], _cwd: &Path) -> std::io::Result { + self.0.borrow_mut().push(argv.to_vec()); + Ok(QueryResult { + code: Some(0), + stdout: Vec::new(), + stderr: Vec::new(), + }) + } +} + +#[test] +fn the_tool_cquery_carries_the_shared_settings_and_ends_with_the_label() { + let recorder = Recorder(RefCell::new(Vec::new())); + let bazel = QueryBazel { + query_runner: &recorder, + workspace: Path::new("."), + startup: &[], + }; + bazel.cquery_files("//t:tool", &[]).expect("cquery"); + let calls = recorder.0.borrow(); + let argv = &calls[0]; + for setting in dx_plan::plan::shared_settings("cquery") { + assert!(argv.contains(&setting), "{setting}"); + } + assert_eq!(argv.last().map(String::as_str), Some("//t:tool")); +} + +#[test] +fn a_pinned_date_makes_the_audit_date_reproducible() { + assert_eq!(today_utc_from(Some("2020-01-02".to_owned())), "2020-01-02"); + assert_eq!(AUDIT_TODAY_ENV, "DX_AUDIT_TODAY"); +} + +#[test] +fn an_unusable_pin_falls_back_to_today() { + for bad in [ + "", + "2020-1-2", + "2020-01-02T00:00", + "not-a-date", + "2020/01/02x", + ] { + let today = today_utc_from(None); + assert_eq!( + today_utc_from(Some(bad.to_owned())), + today, + "{bad:?} is not a pinned date" + ); + } +} diff --git a/cli/cli/src/exec/common.rs b/cli/cli/src/exec/common.rs index e4c6792ca1..371dbc27bc 100644 --- a/cli/cli/src/exec/common.rs +++ b/cli/cli/src/exec/common.rs @@ -1116,1219 +1116,5 @@ pub(crate) fn change_event_for(change: &FileChange) -> ChangeEvent { } #[cfg(test)] -mod tests { - #[test] - fn from_bazel_exit_classifies_every_code() { - let table = [ - (0, false, PhaseKind::Clean), - (0, true, PhaseKind::Clean), - (1, false, PhaseKind::Incomplete), - (2, false, PhaseKind::Selection), - (3, false, PhaseKind::Incomplete), - (3, true, PhaseKind::Findings), - (4, false, PhaseKind::Selection), - (8, false, PhaseKind::Cancelled), - (33, false, PhaseKind::Host), - (36, false, PhaseKind::Tool), - (37, false, PhaseKind::Host), - ]; - for (code, findings, kind) in table { - assert_eq!(PhaseKind::from_bazel_exit(code, findings), kind, "{code}"); - } - } - - use super::*; - - #[test] - fn actions_line_formats_action_counts() { - let tally = ActionTally::default(); - let mut err = Vec::new(); - print_actions(OutputMode::Text { quiet: false }, &tally, &mut err); - assert!(err.is_empty()); - let counts = ActionCounts { - created: 50, - executed: 3, - disk: 1, - remote: 2, - local: 4, - }; - tally.record(counts); - tally.record(counts); - print_actions(OutputMode::Json, &tally, &mut err); - print_actions(OutputMode::Text { quiet: true }, &tally, &mut err); - assert!(err.is_empty()); - print_actions(OutputMode::Text { quiet: false }, &tally, &mut err); - assert_eq!( - String::from_utf8(err).expect("utf8"), - "dx: 20 actions, 6 executed, 14 cached\n" - ); - } - use dx_output::{FinishedCounts, command_finished}; - use std::cell::RefCell; - use std::path::PathBuf; - use std::rc::Rc; - - struct Flaky { - written: Vec, - calls: usize, - fail_on: usize, - kind: io::ErrorKind, - } - - impl Write for Flaky { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.calls += 1; - if self.calls == self.fail_on { - return Err(io::Error::new(self.kind, "stdout failed")); - } - self.written.extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - #[test] - fn phase_effects_touched_counts_changes_and_refusals() { - assert!(!PhaseEffects::default().touched()); - let applied_only = PhaseEffects { - applied: 1, - ..PhaseEffects::default() - }; - assert!(!applied_only.touched()); - for effects in [ - PhaseEffects { - changes: 1, - applied: 1, - ..PhaseEffects::default() - }, - PhaseEffects { - not_applied: 1, - ..PhaseEffects::default() - }, - ] { - assert!(effects.touched(), "{effects:?}"); - } - } - - fn flaky(fail_on: usize, kind: io::ErrorKind) -> Flaky { - Flaky { - written: Vec::new(), - calls: 0, - fail_on, - kind, - } - } - - #[test] - fn latched_out_refuses_every_write_after_a_broken_pipe() { - let mut inner = flaky(2, io::ErrorKind::BrokenPipe); - let mut latched = LatchedOut::new(&mut inner); - latched.write_all(b"first\n").expect("first write lands"); - assert!(latched.write_all(b"second\n").is_err()); - assert!(latched.write_all(b"third\n").is_err()); - assert!(latched.flush().is_err()); - assert_eq!(latched.exit_code(0), 141); - assert_eq!(inner.written, b"first\n"); - assert_eq!(inner.calls, 2, "nothing reaches stdout after the failure"); - } - - #[test] - fn latched_out_maps_other_failures_to_operational() { - let mut inner = flaky(1, io::ErrorKind::Other); - let mut latched = LatchedOut::new(&mut inner); - assert!(latched.write_all(b"first\n").is_err()); - assert_eq!(latched.exit_code(0), 1); - } - - #[test] - fn latched_out_retries_interrupted_writes_and_keeps_the_code() { - let mut inner = flaky(1, io::ErrorKind::Interrupted); - let mut latched = LatchedOut::new(&mut inner); - latched - .write_all(b"first\n") - .expect("interrupted writes retry"); - assert_eq!(latched.exit_code(3), 3); - assert_eq!(inner.written, b"first\n"); - } - - struct ProbeRunner { - code: Option, - signal: Option, - spawn_error: bool, - #[allow(clippy::type_complexity)] - seen: Rc, Vec<(String, String)>)>>, - free: Option, - probed: RefCell>, - } - - impl Runner for ProbeRunner { - fn free_disk_bytes(&self, path: &Path) -> Option { - self.probed.borrow_mut().push(path.to_path_buf()); - self.free - } - - fn run( - &self, - argv: &[String], - _cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - if self.spawn_error { - return Err(io::Error::other("fake launch failure")); - } - *self.seen.borrow_mut() = ( - argv.to_vec(), - env.iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) - .collect(), - ); - Ok(dx_process::ChildStatus { - code: self.code, - signal: self.signal, - }) - } - } - - fn bazel_invocation() -> Invocation { - dx_plan::args::parse(&["build".to_owned(), "//...".to_owned()]).expect("parse") - } - - #[test] - fn run_bazel_forwards_argv_and_env_and_returns_the_exit_code() { - let seen = Rc::new(RefCell::new((Vec::new(), Vec::new()))); - let runner = ProbeRunner { - code: Some(7), - signal: None, - spawn_error: false, - seen: Rc::clone(&seen), - free: None, - probed: RefCell::new(Vec::new()), - }; - let invocation = bazel_invocation(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let argv = vec!["bazel".to_owned(), "build".to_owned(), "//...".to_owned()]; - let code = run_bazel( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &argv, - &[("DX_BUILD_MODE", "release")], - ); - assert_eq!(code, Ok(7)); - let seen = seen.borrow(); - assert_eq!(seen.0, argv); - assert_eq!( - seen.1, - vec![("DX_BUILD_MODE".to_owned(), "release".to_owned())] - ); - assert_eq!(err, Vec::::new()); - } - - /// Records which runner method each call came through. - #[derive(Default)] - struct CallRunner { - calls: RefCell>, - } - - impl Runner for CallRunner { - fn run( - &self, - _argv: &[String], - _cwd: &Path, - _env: &[(&str, &str)], - ) -> io::Result { - unreachable!("builds go through run_withdrawable") // LCOV_EXCL_LINE - reason: the build never takes this path, issue: 8104, policy: docs/cli/commands/build-test-coverage.md - } - - fn run_withdrawable( - &self, - _argv: &[String], - _cwd: &Path, - _env: &[(&str, &str)], - withdraw: &dyn Fn() -> bool, - ) -> io::Result> { - self.calls.borrow_mut().push("run_withdrawable"); - Ok((!withdraw()).then_some(dx_process::ChildStatus { - code: Some(7), - signal: None, - })) - } - - fn run_app( - &self, - _argv: &[String], - _cwd: &Path, - _env: &[(&str, &str)], - withdraw: Option<&dyn Fn() -> bool>, - _held: &[&std::fs::File], - ) -> io::Result> { - self.calls.borrow_mut().push("run_app"); - Ok( - (!withdraw.is_some_and(|withdraw| withdraw())).then_some(dx_process::ChildStatus { - code: Some(7), - signal: None, - }), - ) - } - } - - #[test] - fn a_withdrawable_build_keeps_its_stderr_and_holds_nothing() { - let runner = CallRunner::default(); - let invocation = bazel_invocation(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let argv = vec!["bazel".to_owned(), "build".to_owned(), "//...".to_owned()]; - let mut build = |withdraw: &dyn Fn() -> bool| { - run_bazel_withdrawable( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &argv, - withdraw, - ) - }; - assert_eq!(build(&|| false), Ok(Some(7))); - assert_eq!(build(&|| true), Ok(None)); - assert_eq!(*runner.calls.borrow(), ["run_withdrawable"; 2]); - } - - #[test] - fn a_withdrawable_app_keeps_its_tty_and_goes_through_run_app() { - let runner = CallRunner::default(); - let invocation = bazel_invocation(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let argv = vec!["bazel".to_owned(), "run".to_owned(), "//:app".to_owned()]; - let mut app = |withdraw: &dyn Fn() -> bool| { - run_bazel_app_withdrawable( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &argv, - withdraw, - ) - .map(|exit| exit.map(|exit| exit.code)) - }; - assert_eq!(app(&|| false), Ok(Some(7))); - assert_eq!(app(&|| true), Ok(None)); - assert_eq!(*runner.calls.borrow(), ["run_app"; 2]); - } - - #[test] - fn run_bazel_reports_a_spawn_failure_and_asks_the_caller_to_stop() { - let runner = ProbeRunner { - code: Some(0), - signal: None, - spawn_error: true, - seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), - free: None, - probed: RefCell::new(Vec::new()), - }; - let invocation = bazel_invocation(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run_bazel( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &["bazel".to_owned()], - &[], - ); - assert_eq!(code, Err(operational_code())); - assert_eq!( - String::from_utf8(err).expect("utf8"), - "dx: launch_failed: failed to launch Bazel: fake launch failure\n\ - next: install bazelisk (or bazel) on PATH, using the version pinned in .bazelversion, and run the command again.\n" - ); - } - - #[test] - fn run_bazel_reports_a_signalled_bazel_and_asks_the_caller_to_stop() { - let runner = ProbeRunner { - code: None, - signal: None, - spawn_error: false, - seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), - free: None, - probed: RefCell::new(Vec::new()), - }; - let invocation = bazel_invocation(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let code = run_bazel( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &["bazel".to_owned()], - &[], - ); - assert_eq!(code, Err(operational_code())); - assert_eq!( - String::from_utf8(err).expect("utf8"), - "dx: bazel_signalled: Bazel terminated by signal\n" - ); - } - - fn disk_runner(code: i32, free: Option) -> ProbeRunner { - ProbeRunner { - code: Some(code), - signal: None, - spawn_error: false, - seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), - free, - probed: RefCell::new(Vec::new()), - } - } - - fn run_on_disk(runner: &ProbeRunner, output: OutputMode) -> (Result, String, String) { - let (mut out, mut err) = (Vec::new(), Vec::new()); - let argv = vec![ - "bazel".to_owned(), - "--output_base=/ob".to_owned(), - "build".to_owned(), - ]; - let code = run_bazel_output( - output, - &mut out, - &mut err, - Path::new("/ws"), - runner, - &argv, - &[], - ); - ( - code, - String::from_utf8(out).expect("utf8"), - String::from_utf8(err).expect("utf8"), - ) - } - - #[test] - fn run_bazel_app_keeps_exit_36_on_a_nearly_full_disk() { - for output in [OutputMode::Text { quiet: false }, OutputMode::Json] { - let runner = disk_runner(36, Some(1 << 20)); - let mut invocation = bazel_invocation(); - invocation.output = output; - let (mut out, mut err) = (Vec::new(), Vec::new()); - let code = run_bazel_app( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &["bazel".to_owned(), "run".to_owned(), "//app".to_owned()], - &[], - ); - assert_eq!( - code, - Ok(AppExit { - code: 36, - signal: None - }) - ); - assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out)); - let err = String::from_utf8(err).expect("utf8"); - assert!(!err.contains("disk_"), "{err}"); - assert!(runner.probed.borrow().is_empty()); - } - } - - #[test] - fn run_bazel_app_still_reports_spawn_failures_and_signals() { - for (spawn_error, code, expected) in [ - (true, Some(0), "launch_failed"), - (false, None, "bazel_signalled"), - ] { - let runner = ProbeRunner { - code, - signal: None, - spawn_error, - seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), - free: Some(1 << 20), - probed: RefCell::new(Vec::new()), - }; - let invocation = bazel_invocation(); - let (mut out, mut err) = (Vec::new(), Vec::new()); - let exit = run_bazel_app( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &["bazel".to_owned()], - &[], - ); - assert_eq!(exit, Err(operational_code())); - let err = String::from_utf8(err).expect("utf8"); - assert!(err.starts_with(&format!("dx: {expected}: ")), "{err}"); - assert!(runner.probed.borrow().is_empty()); - } - } - - fn signal_runner(signal: Option) -> ProbeRunner { - ProbeRunner { - code: None, - signal, - spawn_error: false, - seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), - free: None, - probed: RefCell::new(Vec::new()), - } - } - - #[test] - fn run_bazel_app_maps_a_signalled_app_to_128_plus_the_signal() { - for output in [OutputMode::Text { quiet: false }, OutputMode::Json] { - let runner = signal_runner(Some(11)); - let mut invocation = bazel_invocation(); - invocation.output = output; - let (mut out, mut err) = (Vec::new(), Vec::new()); - let exit = run_bazel_app( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &["bazel".to_owned(), "run".to_owned(), "//app".to_owned()], - &[], - ); - assert_eq!( - exit, - Ok(AppExit { - code: 139, - signal: Some(11) - }) - ); - assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out)); - assert!(err.is_empty(), "{}", String::from_utf8_lossy(&err)); - } - } - - #[test] - fn run_bazel_keeps_bazel_signalled_when_the_signal_is_known() { - let runner = signal_runner(Some(11)); - let invocation = bazel_invocation(); - let (mut out, mut err) = (Vec::new(), Vec::new()); - let code = run_bazel( - &invocation, - &mut out, - &mut err, - Path::new("/ws"), - &runner, - &["bazel".to_owned()], - &[], - ); - assert_eq!(code, Err(operational_code())); - assert_eq!( - String::from_utf8(err).expect("utf8"), - "dx: bazel_signalled: Bazel terminated by signal\n" - ); - } - - #[test] - fn disk_full_names_exit_36_on_a_nearly_full_disk() { - let runner = disk_runner(36, Some(1 << 20)); - let (code, _, err) = run_on_disk(&runner, OutputMode::Text { quiet: false }); - assert_eq!(code, Err(36)); - assert!( - err.contains("dx: disk_low: 1.0 MiB free at /ws; free disk space with `dx worktree prune --apply`\n"), - "{err}" - ); - assert!( - err.contains( - "dx: disk_full: Bazel exited 36 with 1.0 MiB free at /ws; the disk is full\n" - ), - "{err}" - ); - assert!( - err.contains("next: free disk space (try `dx worktree prune --apply`) and run the command again.\n"), - "{err}" - ); - assert!(runner.probed.borrow().contains(&PathBuf::from("/ob"))); - - let runner = disk_runner(36, Some(1 << 20)); - let (code, out, _) = run_on_disk(&runner, OutputMode::Json); - assert_eq!(code, Err(36)); - let events: Vec = out - .lines() - .map(|line| serde_json::from_str(line).expect("NDJSON")) - .collect(); - let notices: Vec<_> = events.iter().filter(|e| e["event"] == "notice").collect(); - assert_eq!(notices.len(), 1, "{out}"); - assert_eq!(notices[0]["code"], "disk_low"); - assert_eq!(notices[0]["severity"], "warning"); - let errors: Vec<_> = events.iter().filter(|e| e["event"] == "error").collect(); - assert_eq!(errors.len(), 1, "{out}"); - assert_eq!(errors[0]["code"], "disk_full"); - assert_eq!(errors[0]["cause"], "host"); - assert_eq!(errors[0]["status"], 36); - assert_eq!(errors[0]["phase"], "execute"); - let finished: Vec<_> = events - .iter() - .filter(|e| e["event"] == "command_finished") - .collect(); - assert_eq!(finished.len(), 1, "{out}"); - assert_eq!(finished[0]["exit_code"], 36); - } - - #[test] - fn exit_36_with_room_on_disk_keeps_the_bazel_exit() { - let runner = disk_runner(36, Some(100 << 30)); - let (code, out, err) = run_on_disk(&runner, OutputMode::Json); - assert_eq!(code, Ok(36)); - assert_eq!(out, ""); - assert_eq!(err, ""); - } - - #[test] - fn other_exits_on_a_nearly_full_disk_only_warn() { - let runner = disk_runner(1, Some(1 << 20)); - let (code, _, err) = run_on_disk(&runner, OutputMode::Text { quiet: false }); - assert_eq!(code, Ok(1)); - assert!(err.starts_with("dx: disk_low: "), "{err}"); - assert!(!err.contains("disk_full"), "{err}"); - assert_eq!( - runner.probed.borrow().len(), - dx_plan::disk::disk_paths(Path::new("/ws"), &["--output_base=/ob".to_owned()]).len() - ); - } - - #[test] - fn exit_36_without_a_disk_reading_keeps_the_bazel_exit() { - let runner = disk_runner(36, None); - let (code, out, err) = run_on_disk(&runner, OutputMode::Json); - assert_eq!(code, Ok(36)); - assert_eq!(out, ""); - assert_eq!(err, ""); - } - - #[test] - fn hex_digest_formats_lowercase_hex() { - assert_eq!(hex_digest(&[0xabu8; 32]), "ab".repeat(32)); - assert_eq!(hex_digest(&[0u8; 32]).len(), 64); - } - - #[test] - fn apply_rejects_overlapping_edits_without_writing() { - let original = b"abcdef"; - assert!(apply_to_bytes(original, &[(0, 2, "AB".to_owned())]).is_some()); - assert!( - apply_to_bytes(original, &[(0, 2, "AB".to_owned()), (1, 3, "X".to_owned())]).is_none() - ); - assert!( - apply_to_bytes(original, &[(0, 6, "AB".to_owned()), (6, 7, "X".to_owned())]).is_none() - ); - assert!(apply_to_bytes(b"\xff\xfe", &[(0, 1, "a".to_owned())]).is_none()); - } - - fn finding(path: Option<&str>, range: Option<(u64, u64)>) -> FindingRecord { - FindingRecord::single(DiagnosticEvent { - rule: Some("rule".to_owned()), - path: path.map(str::to_owned), - range, - fixable: true, - ..DiagnosticEvent::new(Severity::Warning, "lint-tool", "mapped") - }) - } - - fn at(line: u64, column: u64) -> dx_output::Position { - dx_output::Position { line, column } - } - - #[test] - fn text_finding_prints_the_verified_location_tool_rule_severity_and_message() { - let mut record = finding(Some("src/a.py"), Some((4, 6))); - record.span = Some((at(2, 3), at(2, 5))); - assert_eq!( - text_finding(&record), - "src/a.py:2:3: warning: mapped [lint-tool/rule] (fixable)" - ); - record.count = 3; - record.diagnostic.fixable = false; - assert_eq!( - text_finding(&record), - "src/a.py:2:3: warning: mapped [lint-tool/rule] (x3)" - ); - } - - #[test] - fn text_finding_colors_severity_only_when_asked() { - let mut record = finding(Some("src/a.py"), None); - record.diagnostic.severity = Severity::Error; - assert!(text_finding_colored(&record, true).contains("\x1b[31merror\x1b[0m")); - assert!(!text_finding_colored(&record, false).contains('\x1b')); - } - - #[test] - fn summary_counts_findings_files_and_fixable() { - let mut error = DiagnosticEvent::new(Severity::Error, "t", "m"); - error.path = Some("a.py".to_owned()); - let mut warning = DiagnosticEvent::new(Severity::Warning, "t", "m"); - warning.path = Some("a.py".to_owned()); - warning.fixable = true; - assert_eq!( - findings_summary(&[error, warning], 3), - "1 error(s), 1 warning(s) in 1 file(s), 1 fixable with --apply" - ); - assert_eq!(findings_summary(&[], 3), "no findings in 3 target(s)"); - } - - #[test] - fn text_finding_does_not_repeat_a_tool_prefix_in_the_rule() { - let mut record = finding(Some("src/a.py"), None); - record.diagnostic.tool = "vuln".to_owned(); - record.diagnostic.rule = Some("vuln/GHSA-1".to_owned()); - assert!(text_finding(&record).contains("[vuln/GHSA-1]")); - } - - #[test] - fn text_finding_says_why_a_finding_is_not_fixable() { - let cases = [ - ( - "unstable", - "src/a.py: warning: mapped [lint-tool/rule] (not fixable: unstable)", - ), - ( - "no_change", - "src/a.py: warning: mapped [lint-tool/rule] (not fixable: no_change)", - ), - ( - "persists", - "src/a.py: warning: mapped [lint-tool/rule] (not fixable: persists)", - ), - ]; - for (reason, line) in cases { - let mut record = finding(Some("src/a.py"), None); - record.diagnostic.fixable = false; - record.diagnostic.unfixable_reason = Some(reason.to_owned()); - assert_eq!(text_finding(&record), line); - } - } - - #[test] - fn text_finding_never_guesses_a_location() { - let unverified = finding(Some("src/a.py"), Some((4, 6))); - assert_eq!( - text_finding(&unverified), - "src/a.py: warning: mapped [lint-tool/rule] (fixable)" - ); - let mut pathless = finding(None, None); - pathless.diagnostic.rule = None; - pathless.diagnostic.fixable = false; - assert_eq!( - text_finding(&pathless), - "(no file): warning: mapped [lint-tool]" - ); - let mut stray = finding(Some("src/a.py"), None); - stray.span = Some((at(9, 9), at(9, 9))); - assert!(text_finding(&stray).starts_with("src/a.py: warning")); - } - - #[test] - fn text_finding_places_related_items_and_suggestions() { - let mut record = finding(None, None); - record.diagnostic.fixable = false; - record.diagnostic.related = vec![ - dx_output::RelatedEvent { - role: "label".to_owned(), - message: "expected `i32`".to_owned(), - path: Some("src/b.rs".to_owned()), - range: Some((1, 2)), - replacement: None, - applicability: None, - }, - dx_output::RelatedEvent { - role: "help".to_owned(), - message: "try this".to_owned(), - path: None, - range: None, - replacement: Some("1".to_owned()), - applicability: Some("MaybeIncorrect".to_owned()), - }, - ]; - record.related_spans = vec![Some((at(7, 2), at(7, 3))), None]; - assert_eq!( - text_finding(&record), - "(no file): warning: mapped [lint-tool/rule]\n label: expected `i32` (src/b.rs:7:2)\n help: try this suggests `1` [MaybeIncorrect]" - ); - } - - #[test] - fn apply_rejects_boundary_and_encoding_violations() { - assert!(apply_to_bytes("héllo".as_bytes(), &[(2, 3, "X".to_owned())]).is_none()); - assert!(apply_to_bytes("héllo".as_bytes(), &[(1, 2, "X".to_owned())]).is_none()); - assert!(apply_to_bytes(b"\xff", &[(0, 1, "X".to_owned())]).is_none()); - assert_eq!( - apply_to_bytes(b"ab", &[(0, 1, "X".to_owned())]), - Some(b"Xb".to_vec()) - ); - } - - #[test] - fn change_event_is_deterministic_and_reconstructs_candidate() { - let original = b"BAD\n"; - let terminal = "GOOD\n"; - let change = FileChange { - path: "src/lib.rs".to_owned(), - original_digest: digest(original), - edits: vec![(0, original.len() as u64, terminal.to_owned())], - }; - let first = change_event_for(&change); - let second = change_event_for(&change); - assert_eq!(first.path, "src/lib.rs"); - assert_eq!(first.path, second.path); - assert_eq!(first.source_digest, second.source_digest); - assert_eq!(first.source_digest, Some(hex_digest(&digest(original)))); - assert_eq!(first.edits.len(), 1); - assert_eq!(first.edits[0].start, 0); - assert_eq!(first.edits[0].end, original.len() as u64); - assert_eq!(first.edits[0].replacement, terminal); - assert_eq!( - first, second, - "the event must not depend on how many times it is built" - ); - let planned = apply_to_bytes(original, &change.edits).expect("apply"); - assert_eq!(planned, terminal.as_bytes()); - let other = FileChange { - path: "src/other.rs".to_owned(), - original_digest: digest(original), - edits: vec![(0, original.len() as u64, terminal.to_owned())], - }; - let other_event = change_event_for(&other); - assert_ne!(first.path, other_event.path); - } - - struct BrokenPipeWriter; - - impl Write for BrokenPipeWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) - } - - fn flush(&mut self) -> io::Result<()> { - Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) - } - } - - struct FailingWriter; - - impl Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::other("boom")) - } - - fn flush(&mut self) -> io::Result<()> { - Err(io::Error::other("boom")) - } - } - - #[test] - fn stdout_helpers_map_broken_pipe_to_141() { - assert_eq!(dx_process::broken_pipe_code(), 128 + 13); - let broken = io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"); - assert_eq!(stdout_io_code(&broken), 128 + 13); - let other = io::Error::other("boom"); - assert_eq!(stdout_io_code(&other), operational_code()); - let broken_out = dx_output::OutputError::from(io::Error::from(io::ErrorKind::BrokenPipe)); - assert!(broken_out.is_broken_pipe()); - assert_eq!(stdout_output_code(&broken_out), 128 + 13); - let other_out = dx_output::OutputError::from(io::Error::other("boom")); - assert!(!other_out.is_broken_pipe()); - assert_eq!(stdout_output_code(&other_out), operational_code()); - } - - #[test] - fn emit_event_and_flush_fail_with_broken_pipe_code() { - let event = command_finished(0, &FinishedCounts::default()); - assert_eq!(emit_event(&mut BrokenPipeWriter, &event), Err(128 + 13)); - assert_eq!(emit_event(&mut FailingWriter, &event), Err(1)); - assert_eq!(flush_out(&mut BrokenPipeWriter), Err(128 + 13)); - assert_eq!(flush_out(&mut FailingWriter), Err(1)); - assert!( - check_stdout_write(Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "broken pipe" - ))) - .is_err() - ); - } - - #[test] - fn report_failed_writes_the_line_and_only_json_emits_the_event() { - let mut out = Vec::new(); - let mut err = Vec::new(); - assert_eq!( - report_failed( - &mut out, - &mut err, - OutputMode::Text { quiet: false }, - "boom", - ), - Ok(()) - ); - assert_eq!( - String::from_utf8(err).unwrap(), - "dx: report_failed: boom\nnext: make the report destination writable and run the command again.\n" - ); - assert!(out.is_empty()); - - let mut out = Vec::new(); - let mut err = Vec::new(); - assert_eq!( - report_failed(&mut out, &mut err, OutputMode::Diff, "boom"), - Ok(()) - ); - assert_eq!( - String::from_utf8(err).unwrap(), - "dx: report_failed: boom\nnext: make the report destination writable and run the command again.\n" - ); - assert!(out.is_empty()); - - let mut out = Vec::new(); - let mut err = Vec::new(); - assert_eq!( - report_failed(&mut out, &mut err, OutputMode::Json, "boom"), - Ok(()) - ); - assert_eq!( - String::from_utf8(err).unwrap(), - "dx: report_failed: boom\nnext: make the report destination writable and run the command again.\n" - ); - let event: serde_json::Value = serde_json::from_slice(&out).unwrap(); - assert_eq!(event["code"], CODE_REPORT_FAILED); - assert_eq!(event["message"], "boom"); - } - - #[test] - fn report_failed_with_next_replaces_the_default_next_step() { - let mut err = Vec::new(); - assert_eq!( - report_failed_with_next( - &mut Vec::new(), - &mut err, - OutputMode::Text { quiet: false }, - "boom", - Some(super::super::guidance::EVIDENCE_GAP_NEXT), - ), - Ok(()) - ); - let text = String::from_utf8(err).unwrap(); - assert!(text.contains("fix the test or coverage problem"), "{text}"); - assert!(!text.contains("writable"), "{text}"); - } - - #[test] - fn report_failed_returns_the_stdout_code_when_its_event_cannot_land() { - let mut err = Vec::new(); - assert_eq!( - report_failed(&mut BrokenPipeWriter, &mut err, OutputMode::Json, "boom"), - Err(128 + 13) - ); - } - - #[test] - fn emit_built_writes_a_built_event_like_emit_event() { - let event = command_finished(0, &FinishedCounts::default()); - let mut out = Vec::new(); - assert_eq!(emit_built(&mut out, Ok(event.clone())), Ok(())); - let written: serde_json::Value = serde_json::from_slice(&out).unwrap(); - assert_eq!(written, event); - assert_eq!( - emit_built(&mut BrokenPipeWriter, Ok(event.clone())), - emit_event(&mut BrokenPipeWriter, &event) - ); - assert_eq!(emit_built(&mut BrokenPipeWriter, Ok(event)), Err(128 + 13)); - } - - #[test] - fn emit_built_returns_the_write_code_when_the_invalid_result_cannot_land() { - assert_eq!( - emit_built(&mut BrokenPipeWriter, command_started("", "default")), - Err(128 + 13) - ); - assert_eq!( - emit_built(&mut FailingWriter, command_started("", "default")), - Err(operational_code()) - ); - } - - #[test] - fn emit_built_ends_the_stream_when_an_event_fails_to_build() { - let mut out = Vec::new(); - let built = command_started("", "default"); - assert!(built.is_err()); - assert_eq!(emit_built(&mut out, built), Err(operational_code())); - let lines: Vec = String::from_utf8(out) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - assert_eq!(lines.len(), 2, "{lines:?}"); - assert_eq!(lines[0]["event"], "error"); - assert_eq!(lines[0]["code"], CODE_INVALID_RESULT); - assert_eq!(lines[1]["event"], "command_finished"); - assert_eq!(lines[1]["results_complete"], false); - assert_eq!(lines[1]["exit_code"], operational_code()); - } - - #[test] - fn a_later_flush_failure_keeps_the_earlier_write_failure() { - struct WriteThenFlush; - impl Write for WriteThenFlush { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - } - fn flush(&mut self) -> io::Result<()> { - Err(io::Error::from(io::ErrorKind::BrokenPipe)) - } - } - let mut inner = WriteThenFlush; - let mut latched = LatchedOut::new(&mut inner); - assert!(latched.write_all(b"x").is_err()); - assert_eq!(latched.exit_code(0), operational_code()); - } - - #[test] - fn a_flush_failure_alone_fails_a_clean_run() { - let mut inner = BrokenPipeWriter; - let mut latched = LatchedOut::new(&mut inner); - assert_eq!(latched.exit_code(0), 128 + 13); - } - - #[test] - fn write_report_document_honours_the_destination() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut out = Vec::new(); - write_report_document( - &mut out, - dir.path(), - &Destination::File("out.sarif".to_owned()), - "{}\n", - ) - .expect("write"); - assert_eq!( - std::fs::read_to_string(dir.path().join("out.sarif")).expect("report"), - "{}\n" - ); - assert!(out.is_empty()); - - write_report_document(&mut out, dir.path(), &Destination::Stdout, "{}").expect("stdout"); - assert_eq!(String::from_utf8(out).expect("utf8"), "{}\n"); - - let err = write_report_document( - &mut BrokenPipeWriter, - dir.path(), - &Destination::Stdout, - "{}\n", - ) - .expect_err("broken pipe"); - assert_eq!(err.destination, "-"); - assert!(err.reason.contains("broken pipe"), "{err}"); - } - - #[test] - fn write_report_file_refuses_a_parent_that_is_not_a_directory() { - let dir = tempfile::tempdir().expect("tempdir"); - let err = write_report_file(dir.path(), "nested/out.sarif", "{}\n").expect_err("no parent"); - assert!(err.destination.contains("nested"), "{err}"); - assert!(err.destination.contains("out.sarif"), "{err}"); - assert!(err.reason.contains("parent directory"), "{err}"); - assert!(err.reason.contains("does not exist"), "{err}"); - assert!(!dir.path().join("nested").exists()); - std::fs::create_dir(dir.path().join("nested")).expect("mkdir"); - write_report_file(dir.path(), "nested/out.sarif", "{}\n").expect("write"); - assert_eq!( - std::fs::read_to_string(dir.path().join("nested/out.sarif")).expect("report"), - "{}\n" - ); - let err = write_report_file(dir.path(), "nested", "{}\n").expect_err("is a directory"); - assert!(err.reason.contains("destination is a directory"), "{err}"); - } - - #[test] - fn write_report_file_keeps_stale_content_on_failure() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::write(dir.path().join("out.sarif"), "stale\n").expect("stale"); - std::fs::create_dir(dir.path().join("blocked")).expect("mkdir"); - let err = write_report_file(dir.path(), "blocked", "{}\n").expect_err("directory"); - assert!(err.reason.contains("destination is a directory"), "{err}"); - assert_eq!( - std::fs::read_to_string(dir.path().join("out.sarif")).expect("stale kept"), - "stale\n" - ); - } - - #[test] - #[cfg(unix)] - fn write_report_file_reports_unwritable_destinations() { - let dir = tempfile::tempdir().expect("tempdir"); - let locked = dir.path().join("locked"); - std::fs::create_dir(&locked).expect("mkdir"); - let mut permissions = std::fs::metadata(&locked).expect("metadata").permissions(); - permissions.set_readonly(true); - std::fs::set_permissions(&locked, permissions).expect("readonly"); - let result = write_report_file(dir.path(), "locked/out.sarif", "{}\n"); - let mut permissions = std::fs::metadata(&locked).expect("metadata").permissions(); - #[allow(clippy::permissions_set_readonly_false)] - permissions.set_readonly(false); - std::fs::set_permissions(&locked, permissions).expect("writable"); - let err = result.expect_err("unwritable"); - assert!(err.destination.contains("locked"), "{err}"); - assert!(!err.reason.is_empty(), "{err}"); - assert!(!locked.join("out.sarif").exists()); - } - - #[test] - fn write_report_file_resolves_absolute_destinations_as_given() { - let dir = tempfile::tempdir().expect("tempdir"); - let absolute = dir.path().join("abs.sarif").display().to_string(); - write_report_file(dir.path(), &absolute, "{}\n").expect("absolute write"); - assert_eq!( - std::fs::read_to_string(dir.path().join("abs.sarif")).expect("report"), - "{}\n" - ); - } - - #[test] - fn collect_targets_names_the_failure_by_stage() { - let dir = tempfile::tempdir().expect("tempdir"); - let bep = dir.path().join("missing.json"); - let (code, message) = collect_targets(&bep, "dx_results", dir.path()).expect_err("absent"); - assert_eq!(code, CODE_UNREADABLE_BEP); - assert!(message.contains("failed to read build events"), "{message}"); - - std::fs::write(&bep, "").expect("bep"); - let (code, message) = collect_targets(&bep, "", dir.path()).expect_err("empty group"); - assert_eq!(code, CODE_INVALID_BEP); - assert!(message.contains("invalid BEP config"), "{message}"); - - std::fs::write(&bep, "{not json").expect("bep"); - let (code, message) = collect_targets(&bep, "dx_results", dir.path()).expect_err("bad"); - assert_eq!(code, CODE_INVALID_BEP); - assert!(message.contains("invalid build events"), "{message}"); - } - - #[test] - fn collect_targets_rejects_a_cut_off_last_line() { - let dir = tempfile::tempdir().expect("tempdir"); - let bep = dir.path().join("cut.json"); - std::fs::write( - &bep, - "{\"id\": {\"namedSetOfFiles\": {\"id\": \"1\"}}}\n{\"id\":{\"targetCo", - ) - .expect("bep"); - let (code, message) = collect_targets(&bep, "dx_results", dir.path()).expect_err("cut"); - assert_eq!(code, CODE_INVALID_BEP); - assert!( - message.starts_with("invalid build events: line 2: invalid JSON"), - "{message}" - ); - } - - #[test] - fn offline_summary_marks_only_offline_invocations() { - assert_eq!( - offline_summary("Running audit security for //...".to_owned(), true), - "Running audit security for //... (offline, cache-only)" - ); - assert_eq!( - offline_summary("Running audit security for //...".to_owned(), false), - "Running audit security for //..." - ); - } - - #[test] - fn frozen_summary_marks_only_frozen_invocations() { - assert_eq!( - frozen_summary("Running update for //...".to_owned(), true), - "Running update for //... (frozen, no resolution changes)" - ); - assert_eq!( - frozen_summary("Running update for //...".to_owned(), false), - "Running update for //..." - ); - assert_eq!( - frozen_summary( - offline_summary("Running update for //...".to_owned(), true), - true - ), - "Running update for //... (offline, cache-only) (frozen, no resolution changes)" - ); - } - - #[test] - fn startup_outcome_emits_error_then_finished_with_incomplete_results() { - let mut out = Vec::new(); - emit_startup_outcome(&mut out, "invalid_arguments", "boom", 2).expect("events"); - let text = String::from_utf8(out).expect("utf8"); - let mut lines = text.lines(); - let first: serde_json::Value = - serde_json::from_str(lines.next().expect("error")).expect("json"); - assert_eq!(first["event"], "error"); - assert_eq!(first["code"], "invalid_arguments"); - assert_eq!(first["message"], "boom"); - let second: serde_json::Value = - serde_json::from_str(lines.next().expect("finished")).expect("json"); - assert_eq!(second["event"], "command_finished"); - assert_eq!(second["exit_code"], 2); - assert_eq!(second["results_complete"], false); - assert!(lines.next().is_none(), "nothing follows the terminal event"); - } - - #[test] - fn startup_outcome_still_finishes_when_the_code_is_empty() { - let mut out = Vec::new(); - emit_startup_outcome(&mut out, "", "boom", 2).expect("finished anyway"); - let lines: Vec = String::from_utf8(out) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).expect("json")) - .collect(); - assert_eq!(lines.len(), 2, "{lines:?}"); - assert_eq!(lines[0]["event"], "error"); - assert_eq!(lines[0]["code"], CODE_INVALID_RESULT); - assert_eq!(lines[1]["event"], "command_finished"); - assert_eq!(lines[1]["exit_code"], 2); - } - - #[test] - fn startup_outcome_reports_a_broken_stdout() { - assert_eq!( - emit_startup_outcome(&mut BrokenPipeWriter, "invalid_arguments", "boom", 2), - Err(128 + 13) - ); - assert_eq!( - emit_startup_outcome(&mut FailingWriter, "invalid_arguments", "boom", 2), - Err(1) - ); - } - - #[test] - fn operational_returns_broken_pipe_on_truncated_stdout() { - use dx_plan::args::parse; - let invocation = parse(&["status".to_owned(), "--output=json".to_owned()]).expect("parse"); - let mut err = Vec::new(); - let code = operational( - &invocation, - &mut BrokenPipeWriter, - &mut err, - "launch_failed", - "boom", - ); - assert_eq!(code, 128 + 13); - } -} +#[path = "common_tests.rs"] +mod tests; diff --git a/cli/cli/src/exec/common_tests.rs b/cli/cli/src/exec/common_tests.rs new file mode 100644 index 0000000000..12342efb59 --- /dev/null +++ b/cli/cli/src/exec/common_tests.rs @@ -0,0 +1,1212 @@ +#[test] +fn from_bazel_exit_classifies_every_code() { + let table = [ + (0, false, PhaseKind::Clean), + (0, true, PhaseKind::Clean), + (1, false, PhaseKind::Incomplete), + (2, false, PhaseKind::Selection), + (3, false, PhaseKind::Incomplete), + (3, true, PhaseKind::Findings), + (4, false, PhaseKind::Selection), + (8, false, PhaseKind::Cancelled), + (33, false, PhaseKind::Host), + (36, false, PhaseKind::Tool), + (37, false, PhaseKind::Host), + ]; + for (code, findings, kind) in table { + assert_eq!(PhaseKind::from_bazel_exit(code, findings), kind, "{code}"); + } +} + +use super::*; + +#[test] +fn actions_line_formats_action_counts() { + let tally = ActionTally::default(); + let mut err = Vec::new(); + print_actions(OutputMode::Text { quiet: false }, &tally, &mut err); + assert!(err.is_empty()); + let counts = ActionCounts { + created: 50, + executed: 3, + disk: 1, + remote: 2, + local: 4, + }; + tally.record(counts); + tally.record(counts); + print_actions(OutputMode::Json, &tally, &mut err); + print_actions(OutputMode::Text { quiet: true }, &tally, &mut err); + assert!(err.is_empty()); + print_actions(OutputMode::Text { quiet: false }, &tally, &mut err); + assert_eq!( + String::from_utf8(err).expect("utf8"), + "dx: 20 actions, 6 executed, 14 cached\n" + ); +} +use dx_output::{FinishedCounts, command_finished}; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +struct Flaky { + written: Vec, + calls: usize, + fail_on: usize, + kind: io::ErrorKind, +} + +impl Write for Flaky { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.calls += 1; + if self.calls == self.fail_on { + return Err(io::Error::new(self.kind, "stdout failed")); + } + self.written.extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn phase_effects_touched_counts_changes_and_refusals() { + assert!(!PhaseEffects::default().touched()); + let applied_only = PhaseEffects { + applied: 1, + ..PhaseEffects::default() + }; + assert!(!applied_only.touched()); + for effects in [ + PhaseEffects { + changes: 1, + applied: 1, + ..PhaseEffects::default() + }, + PhaseEffects { + not_applied: 1, + ..PhaseEffects::default() + }, + ] { + assert!(effects.touched(), "{effects:?}"); + } +} + +fn flaky(fail_on: usize, kind: io::ErrorKind) -> Flaky { + Flaky { + written: Vec::new(), + calls: 0, + fail_on, + kind, + } +} + +#[test] +fn latched_out_refuses_every_write_after_a_broken_pipe() { + let mut inner = flaky(2, io::ErrorKind::BrokenPipe); + let mut latched = LatchedOut::new(&mut inner); + latched.write_all(b"first\n").expect("first write lands"); + assert!(latched.write_all(b"second\n").is_err()); + assert!(latched.write_all(b"third\n").is_err()); + assert!(latched.flush().is_err()); + assert_eq!(latched.exit_code(0), 141); + assert_eq!(inner.written, b"first\n"); + assert_eq!(inner.calls, 2, "nothing reaches stdout after the failure"); +} + +#[test] +fn latched_out_maps_other_failures_to_operational() { + let mut inner = flaky(1, io::ErrorKind::Other); + let mut latched = LatchedOut::new(&mut inner); + assert!(latched.write_all(b"first\n").is_err()); + assert_eq!(latched.exit_code(0), 1); +} + +#[test] +fn latched_out_retries_interrupted_writes_and_keeps_the_code() { + let mut inner = flaky(1, io::ErrorKind::Interrupted); + let mut latched = LatchedOut::new(&mut inner); + latched + .write_all(b"first\n") + .expect("interrupted writes retry"); + assert_eq!(latched.exit_code(3), 3); + assert_eq!(inner.written, b"first\n"); +} + +struct ProbeRunner { + code: Option, + signal: Option, + spawn_error: bool, + #[allow(clippy::type_complexity)] + seen: Rc, Vec<(String, String)>)>>, + free: Option, + probed: RefCell>, +} + +impl Runner for ProbeRunner { + fn free_disk_bytes(&self, path: &Path) -> Option { + self.probed.borrow_mut().push(path.to_path_buf()); + self.free + } + + fn run( + &self, + argv: &[String], + _cwd: &Path, + env: &[(&str, &str)], + ) -> io::Result { + if self.spawn_error { + return Err(io::Error::other("fake launch failure")); + } + *self.seen.borrow_mut() = ( + argv.to_vec(), + env.iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + ); + Ok(dx_process::ChildStatus { + code: self.code, + signal: self.signal, + }) + } +} + +fn bazel_invocation() -> Invocation { + dx_plan::args::parse(&["build".to_owned(), "//...".to_owned()]).expect("parse") +} + +#[test] +fn run_bazel_forwards_argv_and_env_and_returns_the_exit_code() { + let seen = Rc::new(RefCell::new((Vec::new(), Vec::new()))); + let runner = ProbeRunner { + code: Some(7), + signal: None, + spawn_error: false, + seen: Rc::clone(&seen), + free: None, + probed: RefCell::new(Vec::new()), + }; + let invocation = bazel_invocation(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let argv = vec!["bazel".to_owned(), "build".to_owned(), "//...".to_owned()]; + let code = run_bazel( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &argv, + &[("DX_BUILD_MODE", "release")], + ); + assert_eq!(code, Ok(7)); + let seen = seen.borrow(); + assert_eq!(seen.0, argv); + assert_eq!( + seen.1, + vec![("DX_BUILD_MODE".to_owned(), "release".to_owned())] + ); + assert_eq!(err, Vec::::new()); +} + +/// Records which runner method each call came through. +#[derive(Default)] +struct CallRunner { + calls: RefCell>, +} + +impl Runner for CallRunner { + fn run( + &self, + _argv: &[String], + _cwd: &Path, + _env: &[(&str, &str)], + ) -> io::Result { + unreachable!("builds go through run_withdrawable") // LCOV_EXCL_LINE - reason: the build never takes this path, issue: 8104, policy: docs/cli/commands/build-test-coverage.md + } + + fn run_withdrawable( + &self, + _argv: &[String], + _cwd: &Path, + _env: &[(&str, &str)], + withdraw: &dyn Fn() -> bool, + ) -> io::Result> { + self.calls.borrow_mut().push("run_withdrawable"); + Ok((!withdraw()).then_some(dx_process::ChildStatus { + code: Some(7), + signal: None, + })) + } + + fn run_app( + &self, + _argv: &[String], + _cwd: &Path, + _env: &[(&str, &str)], + withdraw: Option<&dyn Fn() -> bool>, + _held: &[&std::fs::File], + ) -> io::Result> { + self.calls.borrow_mut().push("run_app"); + Ok( + (!withdraw.is_some_and(|withdraw| withdraw())).then_some(dx_process::ChildStatus { + code: Some(7), + signal: None, + }), + ) + } +} + +#[test] +fn a_withdrawable_build_keeps_its_stderr_and_holds_nothing() { + let runner = CallRunner::default(); + let invocation = bazel_invocation(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let argv = vec!["bazel".to_owned(), "build".to_owned(), "//...".to_owned()]; + let mut build = |withdraw: &dyn Fn() -> bool| { + run_bazel_withdrawable( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &argv, + withdraw, + ) + }; + assert_eq!(build(&|| false), Ok(Some(7))); + assert_eq!(build(&|| true), Ok(None)); + assert_eq!(*runner.calls.borrow(), ["run_withdrawable"; 2]); +} + +#[test] +fn a_withdrawable_app_keeps_its_tty_and_goes_through_run_app() { + let runner = CallRunner::default(); + let invocation = bazel_invocation(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let argv = vec!["bazel".to_owned(), "run".to_owned(), "//:app".to_owned()]; + let mut app = |withdraw: &dyn Fn() -> bool| { + run_bazel_app_withdrawable( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &argv, + withdraw, + ) + .map(|exit| exit.map(|exit| exit.code)) + }; + assert_eq!(app(&|| false), Ok(Some(7))); + assert_eq!(app(&|| true), Ok(None)); + assert_eq!(*runner.calls.borrow(), ["run_app"; 2]); +} + +#[test] +fn run_bazel_reports_a_spawn_failure_and_asks_the_caller_to_stop() { + let runner = ProbeRunner { + code: Some(0), + signal: None, + spawn_error: true, + seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), + free: None, + probed: RefCell::new(Vec::new()), + }; + let invocation = bazel_invocation(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run_bazel( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &["bazel".to_owned()], + &[], + ); + assert_eq!(code, Err(operational_code())); + assert_eq!( + String::from_utf8(err).expect("utf8"), + "dx: launch_failed: failed to launch Bazel: fake launch failure\n\ + next: install bazelisk (or bazel) on PATH, using the version pinned in .bazelversion, and run the command again.\n" + ); +} + +#[test] +fn run_bazel_reports_a_signalled_bazel_and_asks_the_caller_to_stop() { + let runner = ProbeRunner { + code: None, + signal: None, + spawn_error: false, + seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), + free: None, + probed: RefCell::new(Vec::new()), + }; + let invocation = bazel_invocation(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = run_bazel( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &["bazel".to_owned()], + &[], + ); + assert_eq!(code, Err(operational_code())); + assert_eq!( + String::from_utf8(err).expect("utf8"), + "dx: bazel_signalled: Bazel terminated by signal\n" + ); +} + +fn disk_runner(code: i32, free: Option) -> ProbeRunner { + ProbeRunner { + code: Some(code), + signal: None, + spawn_error: false, + seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), + free, + probed: RefCell::new(Vec::new()), + } +} + +fn run_on_disk(runner: &ProbeRunner, output: OutputMode) -> (Result, String, String) { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let argv = vec![ + "bazel".to_owned(), + "--output_base=/ob".to_owned(), + "build".to_owned(), + ]; + let code = run_bazel_output( + output, + &mut out, + &mut err, + Path::new("/ws"), + runner, + &argv, + &[], + ); + ( + code, + String::from_utf8(out).expect("utf8"), + String::from_utf8(err).expect("utf8"), + ) +} + +#[test] +fn run_bazel_app_keeps_exit_36_on_a_nearly_full_disk() { + for output in [OutputMode::Text { quiet: false }, OutputMode::Json] { + let runner = disk_runner(36, Some(1 << 20)); + let mut invocation = bazel_invocation(); + invocation.output = output; + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = run_bazel_app( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &["bazel".to_owned(), "run".to_owned(), "//app".to_owned()], + &[], + ); + assert_eq!( + code, + Ok(AppExit { + code: 36, + signal: None + }) + ); + assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out)); + let err = String::from_utf8(err).expect("utf8"); + assert!(!err.contains("disk_"), "{err}"); + assert!(runner.probed.borrow().is_empty()); + } +} + +#[test] +fn run_bazel_app_still_reports_spawn_failures_and_signals() { + for (spawn_error, code, expected) in [ + (true, Some(0), "launch_failed"), + (false, None, "bazel_signalled"), + ] { + let runner = ProbeRunner { + code, + signal: None, + spawn_error, + seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), + free: Some(1 << 20), + probed: RefCell::new(Vec::new()), + }; + let invocation = bazel_invocation(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + let exit = run_bazel_app( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &["bazel".to_owned()], + &[], + ); + assert_eq!(exit, Err(operational_code())); + let err = String::from_utf8(err).expect("utf8"); + assert!(err.starts_with(&format!("dx: {expected}: ")), "{err}"); + assert!(runner.probed.borrow().is_empty()); + } +} + +fn signal_runner(signal: Option) -> ProbeRunner { + ProbeRunner { + code: None, + signal, + spawn_error: false, + seen: Rc::new(RefCell::new((Vec::new(), Vec::new()))), + free: None, + probed: RefCell::new(Vec::new()), + } +} + +#[test] +fn run_bazel_app_maps_a_signalled_app_to_128_plus_the_signal() { + for output in [OutputMode::Text { quiet: false }, OutputMode::Json] { + let runner = signal_runner(Some(11)); + let mut invocation = bazel_invocation(); + invocation.output = output; + let (mut out, mut err) = (Vec::new(), Vec::new()); + let exit = run_bazel_app( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &["bazel".to_owned(), "run".to_owned(), "//app".to_owned()], + &[], + ); + assert_eq!( + exit, + Ok(AppExit { + code: 139, + signal: Some(11) + }) + ); + assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out)); + assert!(err.is_empty(), "{}", String::from_utf8_lossy(&err)); + } +} + +#[test] +fn run_bazel_keeps_bazel_signalled_when_the_signal_is_known() { + let runner = signal_runner(Some(11)); + let invocation = bazel_invocation(); + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = run_bazel( + &invocation, + &mut out, + &mut err, + Path::new("/ws"), + &runner, + &["bazel".to_owned()], + &[], + ); + assert_eq!(code, Err(operational_code())); + assert_eq!( + String::from_utf8(err).expect("utf8"), + "dx: bazel_signalled: Bazel terminated by signal\n" + ); +} + +#[test] +fn disk_full_names_exit_36_on_a_nearly_full_disk() { + let runner = disk_runner(36, Some(1 << 20)); + let (code, _, err) = run_on_disk(&runner, OutputMode::Text { quiet: false }); + assert_eq!(code, Err(36)); + assert!( + err.contains( + "dx: disk_low: 1.0 MiB free at /ws; free disk space with `dx worktree prune --apply`\n" + ), + "{err}" + ); + assert!( + err.contains("dx: disk_full: Bazel exited 36 with 1.0 MiB free at /ws; the disk is full\n"), + "{err}" + ); + assert!( + err.contains( + "next: free disk space (try `dx worktree prune --apply`) and run the command again.\n" + ), + "{err}" + ); + assert!(runner.probed.borrow().contains(&PathBuf::from("/ob"))); + + let runner = disk_runner(36, Some(1 << 20)); + let (code, out, _) = run_on_disk(&runner, OutputMode::Json); + assert_eq!(code, Err(36)); + let events: Vec = out + .lines() + .map(|line| serde_json::from_str(line).expect("NDJSON")) + .collect(); + let notices: Vec<_> = events.iter().filter(|e| e["event"] == "notice").collect(); + assert_eq!(notices.len(), 1, "{out}"); + assert_eq!(notices[0]["code"], "disk_low"); + assert_eq!(notices[0]["severity"], "warning"); + let errors: Vec<_> = events.iter().filter(|e| e["event"] == "error").collect(); + assert_eq!(errors.len(), 1, "{out}"); + assert_eq!(errors[0]["code"], "disk_full"); + assert_eq!(errors[0]["cause"], "host"); + assert_eq!(errors[0]["status"], 36); + assert_eq!(errors[0]["phase"], "execute"); + let finished: Vec<_> = events + .iter() + .filter(|e| e["event"] == "command_finished") + .collect(); + assert_eq!(finished.len(), 1, "{out}"); + assert_eq!(finished[0]["exit_code"], 36); +} + +#[test] +fn exit_36_with_room_on_disk_keeps_the_bazel_exit() { + let runner = disk_runner(36, Some(100 << 30)); + let (code, out, err) = run_on_disk(&runner, OutputMode::Json); + assert_eq!(code, Ok(36)); + assert_eq!(out, ""); + assert_eq!(err, ""); +} + +#[test] +fn other_exits_on_a_nearly_full_disk_only_warn() { + let runner = disk_runner(1, Some(1 << 20)); + let (code, _, err) = run_on_disk(&runner, OutputMode::Text { quiet: false }); + assert_eq!(code, Ok(1)); + assert!(err.starts_with("dx: disk_low: "), "{err}"); + assert!(!err.contains("disk_full"), "{err}"); + assert_eq!( + runner.probed.borrow().len(), + dx_plan::disk::disk_paths(Path::new("/ws"), &["--output_base=/ob".to_owned()]).len() + ); +} + +#[test] +fn exit_36_without_a_disk_reading_keeps_the_bazel_exit() { + let runner = disk_runner(36, None); + let (code, out, err) = run_on_disk(&runner, OutputMode::Json); + assert_eq!(code, Ok(36)); + assert_eq!(out, ""); + assert_eq!(err, ""); +} + +#[test] +fn hex_digest_formats_lowercase_hex() { + assert_eq!(hex_digest(&[0xabu8; 32]), "ab".repeat(32)); + assert_eq!(hex_digest(&[0u8; 32]).len(), 64); +} + +#[test] +fn apply_rejects_overlapping_edits_without_writing() { + let original = b"abcdef"; + assert!(apply_to_bytes(original, &[(0, 2, "AB".to_owned())]).is_some()); + assert!(apply_to_bytes(original, &[(0, 2, "AB".to_owned()), (1, 3, "X".to_owned())]).is_none()); + assert!(apply_to_bytes(original, &[(0, 6, "AB".to_owned()), (6, 7, "X".to_owned())]).is_none()); + assert!(apply_to_bytes(b"\xff\xfe", &[(0, 1, "a".to_owned())]).is_none()); +} + +fn finding(path: Option<&str>, range: Option<(u64, u64)>) -> FindingRecord { + FindingRecord::single(DiagnosticEvent { + rule: Some("rule".to_owned()), + path: path.map(str::to_owned), + range, + fixable: true, + ..DiagnosticEvent::new(Severity::Warning, "lint-tool", "mapped") + }) +} + +fn at(line: u64, column: u64) -> dx_output::Position { + dx_output::Position { line, column } +} + +#[test] +fn text_finding_prints_the_verified_location_tool_rule_severity_and_message() { + let mut record = finding(Some("src/a.py"), Some((4, 6))); + record.span = Some((at(2, 3), at(2, 5))); + assert_eq!( + text_finding(&record), + "src/a.py:2:3: warning: mapped [lint-tool/rule] (fixable)" + ); + record.count = 3; + record.diagnostic.fixable = false; + assert_eq!( + text_finding(&record), + "src/a.py:2:3: warning: mapped [lint-tool/rule] (x3)" + ); +} + +#[test] +fn text_finding_colors_severity_only_when_asked() { + let mut record = finding(Some("src/a.py"), None); + record.diagnostic.severity = Severity::Error; + assert!(text_finding_colored(&record, true).contains("\x1b[31merror\x1b[0m")); + assert!(!text_finding_colored(&record, false).contains('\x1b')); +} + +#[test] +fn summary_counts_findings_files_and_fixable() { + let mut error = DiagnosticEvent::new(Severity::Error, "t", "m"); + error.path = Some("a.py".to_owned()); + let mut warning = DiagnosticEvent::new(Severity::Warning, "t", "m"); + warning.path = Some("a.py".to_owned()); + warning.fixable = true; + assert_eq!( + findings_summary(&[error, warning], 3), + "1 error(s), 1 warning(s) in 1 file(s), 1 fixable with --apply" + ); + assert_eq!(findings_summary(&[], 3), "no findings in 3 target(s)"); +} + +#[test] +fn text_finding_does_not_repeat_a_tool_prefix_in_the_rule() { + let mut record = finding(Some("src/a.py"), None); + record.diagnostic.tool = "vuln".to_owned(); + record.diagnostic.rule = Some("vuln/GHSA-1".to_owned()); + assert!(text_finding(&record).contains("[vuln/GHSA-1]")); +} + +#[test] +fn text_finding_says_why_a_finding_is_not_fixable() { + let cases = [ + ( + "unstable", + "src/a.py: warning: mapped [lint-tool/rule] (not fixable: unstable)", + ), + ( + "no_change", + "src/a.py: warning: mapped [lint-tool/rule] (not fixable: no_change)", + ), + ( + "persists", + "src/a.py: warning: mapped [lint-tool/rule] (not fixable: persists)", + ), + ]; + for (reason, line) in cases { + let mut record = finding(Some("src/a.py"), None); + record.diagnostic.fixable = false; + record.diagnostic.unfixable_reason = Some(reason.to_owned()); + assert_eq!(text_finding(&record), line); + } +} + +#[test] +fn text_finding_never_guesses_a_location() { + let unverified = finding(Some("src/a.py"), Some((4, 6))); + assert_eq!( + text_finding(&unverified), + "src/a.py: warning: mapped [lint-tool/rule] (fixable)" + ); + let mut pathless = finding(None, None); + pathless.diagnostic.rule = None; + pathless.diagnostic.fixable = false; + assert_eq!( + text_finding(&pathless), + "(no file): warning: mapped [lint-tool]" + ); + let mut stray = finding(Some("src/a.py"), None); + stray.span = Some((at(9, 9), at(9, 9))); + assert!(text_finding(&stray).starts_with("src/a.py: warning")); +} + +#[test] +fn text_finding_places_related_items_and_suggestions() { + let mut record = finding(None, None); + record.diagnostic.fixable = false; + record.diagnostic.related = vec![ + dx_output::RelatedEvent { + role: "label".to_owned(), + message: "expected `i32`".to_owned(), + path: Some("src/b.rs".to_owned()), + range: Some((1, 2)), + replacement: None, + applicability: None, + }, + dx_output::RelatedEvent { + role: "help".to_owned(), + message: "try this".to_owned(), + path: None, + range: None, + replacement: Some("1".to_owned()), + applicability: Some("MaybeIncorrect".to_owned()), + }, + ]; + record.related_spans = vec![Some((at(7, 2), at(7, 3))), None]; + assert_eq!( + text_finding(&record), + "(no file): warning: mapped [lint-tool/rule]\n label: expected `i32` (src/b.rs:7:2)\n help: try this suggests `1` [MaybeIncorrect]" + ); +} + +#[test] +fn apply_rejects_boundary_and_encoding_violations() { + assert!(apply_to_bytes("héllo".as_bytes(), &[(2, 3, "X".to_owned())]).is_none()); + assert!(apply_to_bytes("héllo".as_bytes(), &[(1, 2, "X".to_owned())]).is_none()); + assert!(apply_to_bytes(b"\xff", &[(0, 1, "X".to_owned())]).is_none()); + assert_eq!( + apply_to_bytes(b"ab", &[(0, 1, "X".to_owned())]), + Some(b"Xb".to_vec()) + ); +} + +#[test] +fn change_event_is_deterministic_and_reconstructs_candidate() { + let original = b"BAD\n"; + let terminal = "GOOD\n"; + let change = FileChange { + path: "src/lib.rs".to_owned(), + original_digest: digest(original), + edits: vec![(0, original.len() as u64, terminal.to_owned())], + }; + let first = change_event_for(&change); + let second = change_event_for(&change); + assert_eq!(first.path, "src/lib.rs"); + assert_eq!(first.path, second.path); + assert_eq!(first.source_digest, second.source_digest); + assert_eq!(first.source_digest, Some(hex_digest(&digest(original)))); + assert_eq!(first.edits.len(), 1); + assert_eq!(first.edits[0].start, 0); + assert_eq!(first.edits[0].end, original.len() as u64); + assert_eq!(first.edits[0].replacement, terminal); + assert_eq!( + first, second, + "the event must not depend on how many times it is built" + ); + let planned = apply_to_bytes(original, &change.edits).expect("apply"); + assert_eq!(planned, terminal.as_bytes()); + let other = FileChange { + path: "src/other.rs".to_owned(), + original_digest: digest(original), + edits: vec![(0, original.len() as u64, terminal.to_owned())], + }; + let other_event = change_event_for(&other); + assert_ne!(first.path, other_event.path); +} + +struct BrokenPipeWriter; + +impl Write for BrokenPipeWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) + } +} + +struct FailingWriter; + +impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("boom")) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::other("boom")) + } +} + +#[test] +fn stdout_helpers_map_broken_pipe_to_141() { + assert_eq!(dx_process::broken_pipe_code(), 128 + 13); + let broken = io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"); + assert_eq!(stdout_io_code(&broken), 128 + 13); + let other = io::Error::other("boom"); + assert_eq!(stdout_io_code(&other), operational_code()); + let broken_out = dx_output::OutputError::from(io::Error::from(io::ErrorKind::BrokenPipe)); + assert!(broken_out.is_broken_pipe()); + assert_eq!(stdout_output_code(&broken_out), 128 + 13); + let other_out = dx_output::OutputError::from(io::Error::other("boom")); + assert!(!other_out.is_broken_pipe()); + assert_eq!(stdout_output_code(&other_out), operational_code()); +} + +#[test] +fn emit_event_and_flush_fail_with_broken_pipe_code() { + let event = command_finished(0, &FinishedCounts::default()); + assert_eq!(emit_event(&mut BrokenPipeWriter, &event), Err(128 + 13)); + assert_eq!(emit_event(&mut FailingWriter, &event), Err(1)); + assert_eq!(flush_out(&mut BrokenPipeWriter), Err(128 + 13)); + assert_eq!(flush_out(&mut FailingWriter), Err(1)); + assert!( + check_stdout_write(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "broken pipe" + ))) + .is_err() + ); +} + +#[test] +fn report_failed_writes_the_line_and_only_json_emits_the_event() { + let mut out = Vec::new(); + let mut err = Vec::new(); + assert_eq!( + report_failed( + &mut out, + &mut err, + OutputMode::Text { quiet: false }, + "boom", + ), + Ok(()) + ); + assert_eq!( + String::from_utf8(err).unwrap(), + "dx: report_failed: boom\nnext: make the report destination writable and run the command again.\n" + ); + assert!(out.is_empty()); + + let mut out = Vec::new(); + let mut err = Vec::new(); + assert_eq!( + report_failed(&mut out, &mut err, OutputMode::Diff, "boom"), + Ok(()) + ); + assert_eq!( + String::from_utf8(err).unwrap(), + "dx: report_failed: boom\nnext: make the report destination writable and run the command again.\n" + ); + assert!(out.is_empty()); + + let mut out = Vec::new(); + let mut err = Vec::new(); + assert_eq!( + report_failed(&mut out, &mut err, OutputMode::Json, "boom"), + Ok(()) + ); + assert_eq!( + String::from_utf8(err).unwrap(), + "dx: report_failed: boom\nnext: make the report destination writable and run the command again.\n" + ); + let event: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(event["code"], CODE_REPORT_FAILED); + assert_eq!(event["message"], "boom"); +} + +#[test] +fn report_failed_with_next_replaces_the_default_next_step() { + let mut err = Vec::new(); + assert_eq!( + report_failed_with_next( + &mut Vec::new(), + &mut err, + OutputMode::Text { quiet: false }, + "boom", + Some(super::super::guidance::EVIDENCE_GAP_NEXT), + ), + Ok(()) + ); + let text = String::from_utf8(err).unwrap(); + assert!(text.contains("fix the test or coverage problem"), "{text}"); + assert!(!text.contains("writable"), "{text}"); +} + +#[test] +fn report_failed_returns_the_stdout_code_when_its_event_cannot_land() { + let mut err = Vec::new(); + assert_eq!( + report_failed(&mut BrokenPipeWriter, &mut err, OutputMode::Json, "boom"), + Err(128 + 13) + ); +} + +#[test] +fn emit_built_writes_a_built_event_like_emit_event() { + let event = command_finished(0, &FinishedCounts::default()); + let mut out = Vec::new(); + assert_eq!(emit_built(&mut out, Ok(event.clone())), Ok(())); + let written: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(written, event); + assert_eq!( + emit_built(&mut BrokenPipeWriter, Ok(event.clone())), + emit_event(&mut BrokenPipeWriter, &event) + ); + assert_eq!(emit_built(&mut BrokenPipeWriter, Ok(event)), Err(128 + 13)); +} + +#[test] +fn emit_built_returns_the_write_code_when_the_invalid_result_cannot_land() { + assert_eq!( + emit_built(&mut BrokenPipeWriter, command_started("", "default")), + Err(128 + 13) + ); + assert_eq!( + emit_built(&mut FailingWriter, command_started("", "default")), + Err(operational_code()) + ); +} + +#[test] +fn emit_built_ends_the_stream_when_an_event_fails_to_build() { + let mut out = Vec::new(); + let built = command_started("", "default"); + assert!(built.is_err()); + assert_eq!(emit_built(&mut out, built), Err(operational_code())); + let lines: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), 2, "{lines:?}"); + assert_eq!(lines[0]["event"], "error"); + assert_eq!(lines[0]["code"], CODE_INVALID_RESULT); + assert_eq!(lines[1]["event"], "command_finished"); + assert_eq!(lines[1]["results_complete"], false); + assert_eq!(lines[1]["exit_code"], operational_code()); +} + +#[test] +fn a_later_flush_failure_keeps_the_earlier_write_failure() { + struct WriteThenFlush; + impl Write for WriteThenFlush { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + } + let mut inner = WriteThenFlush; + let mut latched = LatchedOut::new(&mut inner); + assert!(latched.write_all(b"x").is_err()); + assert_eq!(latched.exit_code(0), operational_code()); +} + +#[test] +fn a_flush_failure_alone_fails_a_clean_run() { + let mut inner = BrokenPipeWriter; + let mut latched = LatchedOut::new(&mut inner); + assert_eq!(latched.exit_code(0), 128 + 13); +} + +#[test] +fn write_report_document_honours_the_destination() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut out = Vec::new(); + write_report_document( + &mut out, + dir.path(), + &Destination::File("out.sarif".to_owned()), + "{}\n", + ) + .expect("write"); + assert_eq!( + std::fs::read_to_string(dir.path().join("out.sarif")).expect("report"), + "{}\n" + ); + assert!(out.is_empty()); + + write_report_document(&mut out, dir.path(), &Destination::Stdout, "{}").expect("stdout"); + assert_eq!(String::from_utf8(out).expect("utf8"), "{}\n"); + + let err = write_report_document( + &mut BrokenPipeWriter, + dir.path(), + &Destination::Stdout, + "{}\n", + ) + .expect_err("broken pipe"); + assert_eq!(err.destination, "-"); + assert!(err.reason.contains("broken pipe"), "{err}"); +} + +#[test] +fn write_report_file_refuses_a_parent_that_is_not_a_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let err = write_report_file(dir.path(), "nested/out.sarif", "{}\n").expect_err("no parent"); + assert!(err.destination.contains("nested"), "{err}"); + assert!(err.destination.contains("out.sarif"), "{err}"); + assert!(err.reason.contains("parent directory"), "{err}"); + assert!(err.reason.contains("does not exist"), "{err}"); + assert!(!dir.path().join("nested").exists()); + std::fs::create_dir(dir.path().join("nested")).expect("mkdir"); + write_report_file(dir.path(), "nested/out.sarif", "{}\n").expect("write"); + assert_eq!( + std::fs::read_to_string(dir.path().join("nested/out.sarif")).expect("report"), + "{}\n" + ); + let err = write_report_file(dir.path(), "nested", "{}\n").expect_err("is a directory"); + assert!(err.reason.contains("destination is a directory"), "{err}"); +} + +#[test] +fn write_report_file_keeps_stale_content_on_failure() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("out.sarif"), "stale\n").expect("stale"); + std::fs::create_dir(dir.path().join("blocked")).expect("mkdir"); + let err = write_report_file(dir.path(), "blocked", "{}\n").expect_err("directory"); + assert!(err.reason.contains("destination is a directory"), "{err}"); + assert_eq!( + std::fs::read_to_string(dir.path().join("out.sarif")).expect("stale kept"), + "stale\n" + ); +} + +#[test] +#[cfg(unix)] +fn write_report_file_reports_unwritable_destinations() { + let dir = tempfile::tempdir().expect("tempdir"); + let locked = dir.path().join("locked"); + std::fs::create_dir(&locked).expect("mkdir"); + let mut permissions = std::fs::metadata(&locked).expect("metadata").permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&locked, permissions).expect("readonly"); + let result = write_report_file(dir.path(), "locked/out.sarif", "{}\n"); + let mut permissions = std::fs::metadata(&locked).expect("metadata").permissions(); + #[allow(clippy::permissions_set_readonly_false)] + permissions.set_readonly(false); + std::fs::set_permissions(&locked, permissions).expect("writable"); + let err = result.expect_err("unwritable"); + assert!(err.destination.contains("locked"), "{err}"); + assert!(!err.reason.is_empty(), "{err}"); + assert!(!locked.join("out.sarif").exists()); +} + +#[test] +fn write_report_file_resolves_absolute_destinations_as_given() { + let dir = tempfile::tempdir().expect("tempdir"); + let absolute = dir.path().join("abs.sarif").display().to_string(); + write_report_file(dir.path(), &absolute, "{}\n").expect("absolute write"); + assert_eq!( + std::fs::read_to_string(dir.path().join("abs.sarif")).expect("report"), + "{}\n" + ); +} + +#[test] +fn collect_targets_names_the_failure_by_stage() { + let dir = tempfile::tempdir().expect("tempdir"); + let bep = dir.path().join("missing.json"); + let (code, message) = collect_targets(&bep, "dx_results", dir.path()).expect_err("absent"); + assert_eq!(code, CODE_UNREADABLE_BEP); + assert!(message.contains("failed to read build events"), "{message}"); + + std::fs::write(&bep, "").expect("bep"); + let (code, message) = collect_targets(&bep, "", dir.path()).expect_err("empty group"); + assert_eq!(code, CODE_INVALID_BEP); + assert!(message.contains("invalid BEP config"), "{message}"); + + std::fs::write(&bep, "{not json").expect("bep"); + let (code, message) = collect_targets(&bep, "dx_results", dir.path()).expect_err("bad"); + assert_eq!(code, CODE_INVALID_BEP); + assert!(message.contains("invalid build events"), "{message}"); +} + +#[test] +fn collect_targets_rejects_a_cut_off_last_line() { + let dir = tempfile::tempdir().expect("tempdir"); + let bep = dir.path().join("cut.json"); + std::fs::write( + &bep, + "{\"id\": {\"namedSetOfFiles\": {\"id\": \"1\"}}}\n{\"id\":{\"targetCo", + ) + .expect("bep"); + let (code, message) = collect_targets(&bep, "dx_results", dir.path()).expect_err("cut"); + assert_eq!(code, CODE_INVALID_BEP); + assert!( + message.starts_with("invalid build events: line 2: invalid JSON"), + "{message}" + ); +} + +#[test] +fn offline_summary_marks_only_offline_invocations() { + assert_eq!( + offline_summary("Running audit security for //...".to_owned(), true), + "Running audit security for //... (offline, cache-only)" + ); + assert_eq!( + offline_summary("Running audit security for //...".to_owned(), false), + "Running audit security for //..." + ); +} + +#[test] +fn frozen_summary_marks_only_frozen_invocations() { + assert_eq!( + frozen_summary("Running update for //...".to_owned(), true), + "Running update for //... (frozen, no resolution changes)" + ); + assert_eq!( + frozen_summary("Running update for //...".to_owned(), false), + "Running update for //..." + ); + assert_eq!( + frozen_summary( + offline_summary("Running update for //...".to_owned(), true), + true + ), + "Running update for //... (offline, cache-only) (frozen, no resolution changes)" + ); +} + +#[test] +fn startup_outcome_emits_error_then_finished_with_incomplete_results() { + let mut out = Vec::new(); + emit_startup_outcome(&mut out, "invalid_arguments", "boom", 2).expect("events"); + let text = String::from_utf8(out).expect("utf8"); + let mut lines = text.lines(); + let first: serde_json::Value = + serde_json::from_str(lines.next().expect("error")).expect("json"); + assert_eq!(first["event"], "error"); + assert_eq!(first["code"], "invalid_arguments"); + assert_eq!(first["message"], "boom"); + let second: serde_json::Value = + serde_json::from_str(lines.next().expect("finished")).expect("json"); + assert_eq!(second["event"], "command_finished"); + assert_eq!(second["exit_code"], 2); + assert_eq!(second["results_complete"], false); + assert!(lines.next().is_none(), "nothing follows the terminal event"); +} + +#[test] +fn startup_outcome_still_finishes_when_the_code_is_empty() { + let mut out = Vec::new(); + emit_startup_outcome(&mut out, "", "boom", 2).expect("finished anyway"); + let lines: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).expect("json")) + .collect(); + assert_eq!(lines.len(), 2, "{lines:?}"); + assert_eq!(lines[0]["event"], "error"); + assert_eq!(lines[0]["code"], CODE_INVALID_RESULT); + assert_eq!(lines[1]["event"], "command_finished"); + assert_eq!(lines[1]["exit_code"], 2); +} + +#[test] +fn startup_outcome_reports_a_broken_stdout() { + assert_eq!( + emit_startup_outcome(&mut BrokenPipeWriter, "invalid_arguments", "boom", 2), + Err(128 + 13) + ); + assert_eq!( + emit_startup_outcome(&mut FailingWriter, "invalid_arguments", "boom", 2), + Err(1) + ); +} + +#[test] +fn operational_returns_broken_pipe_on_truncated_stdout() { + use dx_plan::args::parse; + let invocation = parse(&["status".to_owned(), "--output=json".to_owned()]).expect("parse"); + let mut err = Vec::new(); + let code = operational( + &invocation, + &mut BrokenPipeWriter, + &mut err, + "launch_failed", + "boom", + ); + assert_eq!(code, 128 + 13); +} diff --git a/cli/cli/src/exec/umbrella.rs b/cli/cli/src/exec/umbrella.rs index c9698db3da..9d65a2594a 100644 --- a/cli/cli/src/exec/umbrella.rs +++ b/cli/cli/src/exec/umbrella.rs @@ -1306,3457 +1306,5 @@ fn git(workspace: &Path, args: &[&str]) -> Option { } #[cfg(test)] -mod tests { - use super::super::test_support::*; - use super::*; - use dx_plan::args::Invocation; - use dx_plan::plan::GENERATE_ENV_INTENDED; - use dx_process::{ChildStatus, Runner}; - use std::cell::{Cell, RefCell}; - use std::io::{self, Write}; - use std::path::Path; - use std::rc::Rc; - - fn setting_args(argv: &[String]) -> std::collections::BTreeSet { - argv.iter() - .filter(|arg| arg.starts_with("--@")) - .cloned() - .collect() - } - - #[test] - fn umbrella_phases_and_test_share_the_bazel_settings() { - let resolved = dx_plan::resolve::ResolvedScope { - scope: dx_process::Scope::Repository, - targets: Vec::new(), - excluded: Vec::new(), - kept: Vec::new(), - }; - let shared: std::collections::BTreeSet = dx_plan::plan::BAZEL_SETTINGS - .iter() - .map(ToString::to_string) - .collect(); - let mut argvs = Vec::new(); - for phase in UMBRELLA_PHASES { - let plan = if phase == Command::Generate { - dx_plan::plan::plan_generate(&resolved, &[], true, &[], Some("/tmp/dx.patch")) - } else { - dx_plan::plan::plan_build(phase, &resolved, &[], "/tmp/bep.json", &[]) - }; - argvs.push((format!("{phase:?}"), plan.expect("phase plan").argv)); - let probe = - dx_plan::plan::plan_probe(phase, &resolved, &[], "/tmp/probe.json", &[], None) - .expect("probe plan"); - if let Some(probe) = probe { - argvs.push((format!("{phase:?} probe"), probe.argv)); - } - } - let docs = - dx_plan::plan::plan_docs(&resolved, &[], "/tmp/bep.json", &[]).expect("docs plan"); - argvs.push(("docs".to_owned(), docs.argv)); - let test = dx_plan::plan::plan_workflow( - dx_plan::plan::WorkflowVerb::Test, - &resolved, - &[], - Some("/tmp/bep.json"), - Some(dx_plan::args::BuildMode::Dev), - &[], - ) - .expect("test plan"); - argvs.push(("test".to_owned(), test.argv)); - let build_mode = dx_plan::args::BuildMode::Dev; - let label = "//app:bin".to_owned(); - let planned = [ - ( - "run", - dx_plan::plan::plan_run(&label, &[], &[], build_mode, &[]), - ), - ( - "run build", - dx_plan::plan::plan_run_build(std::slice::from_ref(&label), &[], build_mode, &[]), - ), - ( - "deploy build", - dx_plan::plan::plan_deploy_build(&label, build_mode, &[]), - ), - ( - "deploy run", - dx_plan::plan::plan_deploy_run(&label, &[], build_mode, &[]), - ), - ]; - for (name, plan) in planned { - argvs.push((name.to_owned(), plan.expect("plan").argv)); - } - let plan = dx_plan::plan::plan_managed( - Command::Sync, - &dx_setup::SetupScope::Repository, - &[], - "/tmp/bep.json", - &[], - ) - .expect("managed plan"); - argvs.push(("sync".to_owned(), plan.argv)); - let selection = dx_plan::resolve::SelectionContext::for_test_execution( - dx_plan::args::BuildMode::Dev, - &[], - &[], - ); - let cquery = selection - .selection_argv("cquery", &[], "tests(//...)") - .expect("cquery argv"); - argvs.push(("cquery".to_owned(), cquery)); - for (name, argv) in &argvs { - assert_eq!(setting_args(argv), shared, "{name}: {argv:?}"); - } - let query = selection - .selection_argv("query", &[], "tests(//...)") - .expect("query argv"); - assert!(setting_args(&query).is_empty(), "{query:?}"); - } - - /// Names the capture one umbrella phase writes into the harness temp dir. - fn capture_path(harness: &Harness, phase: &str, index: u64) -> PathBuf { - harness.temp.join(format!( - "umbrella-{phase}-{}-{index}.sarif", - std::process::id() - )) - } - - /// Parses one umbrella invocation against a harness workspace. - fn umbrella_invocation(harness: &Harness, words: &[&str]) -> Invocation { - let parsed = invocation(words); - dx_plan::args::apply_here(&parsed, &harness.workspace, &harness.cwd) - .expect("resolve workspace") - } - - /// How one phase capture is rewritten once its phase has finished. - #[derive(Clone, Copy)] - enum Rewrite { - Remove, - Block, - Bytes(&'static [u8]), - } - - impl Rewrite { - fn apply(self, path: &Path) { - match self { - Rewrite::Remove => { - std::fs::remove_file(path).expect("remove capture"); - } - Rewrite::Block => { - std::fs::remove_file(path).expect("remove capture"); - std::fs::create_dir(path).expect("capture directory"); - } - Rewrite::Bytes(payload) => { - std::fs::write(path, payload).expect("rewrite capture"); - } - } - } - } - - /// Serves the inner runner but exits nonzero from one launch up to an optional end. - struct FailFrom { - inner: R, - from: usize, - to: Option, - code: i32, - launches: Cell, - } - - impl Runner for FailFrom { - fn free_disk_bytes(&self, path: &Path) -> Option { - self.inner.free_disk_bytes(path) - } - - fn run( - &self, - argv: &[String], - cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - if is_shard_probe(argv) { - return self.inner.run(argv, cwd, env); - } - let launch = self.launches.get(); - self.launches.set(launch + 1); - let status = self.inner.run(argv, cwd, env)?; - if launch >= self.from && self.to.is_none_or(|to| launch < to) { - return Ok(ChildStatus::exited(self.code)); - } - Ok(status) - } - } - - /// Serves the inner runner but makes one launch a failed compile with exit 1. - struct CompileFailAt { - inner: R, - at: usize, - bep_line: String, - launches: Cell, - } - - impl Runner for CompileFailAt { - fn free_disk_bytes(&self, path: &Path) -> Option { - self.inner.free_disk_bytes(path) - } - - fn run( - &self, - argv: &[String], - cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - if is_shard_probe(argv) { - return self.inner.run(argv, cwd, env); - } - let launch = self.launches.get(); - self.launches.set(launch + 1); - let status = self.inner.run(argv, cwd, env)?; - if launch != self.at { - return Ok(status); - } - if let Some(bep) = argv - .iter() - .find_map(|arg| arg.strip_prefix("--build_event_json_file=")) - { - let rest = std::fs::read_to_string(bep).unwrap_or_default(); - std::fs::write(bep, format!("{}\n{rest}", self.bep_line)).expect("BEP file"); - } - Ok(ChildStatus::exited(1)) - } - } - - fn compile_fail_at_lint(harness: &Harness) -> CompileFailAt { - let uri = stderr_file(harness, "lint-clippy", RENDERED_E0308); - CompileFailAt { - inner: harness.runner(), - at: 2, - bep_line: failed_action("//app:bin", "c1", "Clippy", "out/bin.clippy", &uri), - launches: Cell::new(0), - } - } - - /// Rewrites one phase capture once the phase that wrote it has finished. - struct RewriteCapture { - inner: R, - capture: PathBuf, - rewrite: Rewrite, - done: Cell, - } - - impl Runner for RewriteCapture { - fn run( - &self, - argv: &[String], - cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - let status = self.inner.run(argv, cwd, env)?; - if !self.done.get() && self.capture.exists() { - self.done.set(true); - self.rewrite.apply(&self.capture); - } - Ok(status) - } - } - - /// Runs one umbrella command whose lint capture is rewritten mid-run. - fn umbrella_with_broken_capture( - harness: Harness, - rewrite: Rewrite, - words: &[&str], - ) -> (Harness, i32, String, String) { - let capture = capture_path(&harness, "lint", 2); - let runner = RewriteCapture { - inner: harness.runner(), - capture, - rewrite, - done: Cell::new(false), - }; - let inv = umbrella_invocation(&harness, words); - let (code, out, err) = harness.execute_with(&inv, &runner); - (harness, code, out, err) - } - - /// Serves apply content for the first `verify_from` launches, then - /// verification content, so `check --apply` tests can stage what the - /// read-only re-check observes after the apply pass wrote sources. - /// Quality launches from the `clean_from`th quality launch on serve - /// `clean_files` instead, so a later phase does not re-offer an edit an - /// earlier phase already wrote. Generate launches never count toward it. - struct StagedRunner { - code: Option, - bep_lines: Vec, - apply_files: Vec<(PathBuf, Vec)>, - clean_files: Vec<(PathBuf, Vec)>, - clean_from: usize, - quality_launches: Cell, - verify_files: Vec<(PathBuf, Vec)>, - apply_intended: Option>, - verify_intended: Option>, - verify_from: usize, - launches: Cell, - #[allow(clippy::type_complexity)] - seen_env: Rc>>>, - } - - impl Runner for StagedRunner { - fn run( - &self, - argv: &[String], - _cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - if is_shard_probe(argv) { - return Ok(ChildStatus { - code: Some(1), - signal: None, - }); - } - let launch = self.launches.get(); - self.launches.set(launch + 1); - let generate = env.iter().any(|(key, _)| *key == GENERATE_ENV_INTENDED); - let quality_launch = self.quality_launches.get(); - if !generate { - self.quality_launches.set(quality_launch + 1); - } - let (files, intended) = if launch >= self.verify_from { - (&self.verify_files, &self.verify_intended) - } else if !generate && quality_launch >= self.clean_from { - (&self.clean_files, &self.apply_intended) - } else { - (&self.apply_files, &self.apply_intended) - }; - for (path, bytes) in files { - std::fs::write(path, bytes).expect("stage artifact"); - } - if let Some(path) = env - .iter() - .find_map(|(key, value)| (*key == GENERATE_ENV_INTENDED).then_some(*value)) - { - if let Some(witness) = intended { - std::fs::write(path, witness).expect("stage witness"); - } - } - self.seen_env.borrow_mut().push( - env.iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) - .collect(), - ); - if let Some(bep) = argv - .iter() - .find_map(|arg| arg.strip_prefix("--build_event_json_file=")) - { - std::fs::write(bep, self.bep_lines.join("\n")).expect("BEP file"); - } - Ok(ChildStatus { - code: self.code, - signal: None, - }) - } - } - - /// Builds one runner serving `harness` content for the apply pass and - /// `verify_files`/`verify_intended` from launch `verify_from` on. - fn staged_runner( - harness: &Harness, - verify_files: Vec<(PathBuf, Vec)>, - verify_intended: Option>, - verify_from: usize, - ) -> StagedRunner { - let staged = harness.staged_artifacts(); - let files: Vec = staged - .iter() - .map(|(artifact, _)| { - serde_json::json!({"uri": format!("file://{}", artifact.to_string_lossy())}) - }) - .collect(); - StagedRunner { - code: Some(0), - bep_lines: vec![ - serde_json::json!({ - "id": {"namedSet": {"id": "0"}}, - "namedSetOfFiles": {"files": files}, - }) - .to_string(), - serde_json::json!({ - "id": {"targetCompleted": {"label": "//test:corpus"}}, - "completed": { - "success": true, - "outputGroup": [{"name": "dx_results", "fileSets": [{"id": "0"}]}], - }, - }) - .to_string(), - LAST_EVENT.to_owned(), - ], - apply_files: staged, - clean_files: Vec::new(), - clean_from: verify_from, - quality_launches: Cell::new(0), - verify_files, - apply_intended: harness.intended.clone(), - verify_intended, - verify_from, - launches: Cell::new(0), - seen_env: Rc::clone(&harness.seen_env), - } - } - - /// Runs one `check --apply` whose launches from `verify_from` observe - /// `verify_files` and `verify_intended` instead of the apply content. - fn fix_apply_with_staged_verify( - harness: &Harness, - verify_files: Vec<(PathBuf, Vec)>, - verify_intended: Option>, - verify_from: usize, - words: &[&str], - ) -> (i32, String, String) { - let runner = staged_runner(harness, verify_files, verify_intended, verify_from); - let inv = umbrella_invocation(harness, words); - harness.execute_with(&inv, &runner) - } - - /// Like `fix_apply_with_staged_verify`, but only format (the first - /// quality launch) sees the harness edit; lint and typecheck see a clean - /// result, as real tools would once format wrote the file. - fn fix_apply_format_edit_then_verify( - harness: &Harness, - verify_files: Vec<(PathBuf, Vec)>, - verify_intended: Option>, - verify_from: usize, - words: &[&str], - ) -> (i32, String, String) { - let mut runner = staged_runner(harness, verify_files, verify_intended, verify_from); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - runner.clean_files = runner - .apply_files - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - runner.clean_from = 1; - let inv = umbrella_invocation(harness, words); - harness.execute_with(&inv, &runner) - } - - /// Reads one merged SARIF document out of a harness workspace. - fn merged_runs(harness: &Harness) -> Vec { - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("out.sarif")).expect("sarif"), - ) - .expect("SARIF JSON"); - assert_eq!(document["version"], serde_json::json!("2.1.0")); - assert_eq!( - document["$schema"], - serde_json::json!("https://json.schemastore.org/sarif-2.1.0.json") - ); - document["runs"].as_array().expect("runs").to_vec() - } - - #[test] - fn check_rejects_build_output_and_names_the_file() { - let harness = umbrella_clean("umbrella-check-build-output"); - let git = |args: &[&str]| { - dx_process::capture::run_checked( - dx_process::capture::git(&harness.workspace).args(args), - dx_process::capture::DEFAULT_TIMEOUT, - ) - .unwrap_or_else(|error| panic!("{error}")); - }; - git(&["init", "-q"]); - harness.write_source("cli/target/debug/dx", "binary"); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("cli/target/debug/dx"), "{out}{err}"); - assert!(!out.contains("Running format"), "{out}"); - assert_eq!(harness.seen_env.borrow().len(), 0); - } - - fn dirty_build_output(name: &str) -> Harness { - let harness = umbrella_clean(name); - dx_process::capture::run_checked( - dx_process::capture::git(&harness.workspace).args(["init", "-q"]), - dx_process::capture::DEFAULT_TIMEOUT, - ) - .unwrap_or_else(|error| panic!("{error}")); - harness.write_source("cli/target/debug/dx", "binary"); - harness - } - - #[test] - fn lint_check_rejects_build_output_without_launching_bazel() { - let harness = dirty_build_output("umbrella-lint-build-output"); - let (code, out, err) = harness.run(&["lint", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("cli/target/debug/dx"), "{out}{err}"); - assert_eq!(harness.seen_env.borrow().len(), 0); - } - - #[test] - fn lint_apply_is_not_refused_for_build_output() { - let harness = dirty_build_output("umbrella-lint-apply-build-output"); - let (_, out, err) = harness.run(&["lint", "--apply", "--output=text"]); - assert!(!err.contains("build output in change"), "{out}{err}"); - assert!(!out.contains("build output in change"), "{out}{err}"); - } - - #[test] - fn check_and_fix_json_report_build_output_before_finishing() { - { - let command = "check"; - let harness = dirty_build_output(&format!("umbrella-{command}-json-build-output")); - let (code, out, err) = harness.run(&[command, "--output=json"]); - assert_eq!(code, 1, "{out}{err}"); - let events = json_events(&out); - let error = events - .iter() - .position(|event| event["code"] == "build_output_in_change") - .unwrap_or_else(|| panic!("{out}")); - let last = events.len() - 1; - assert!(error < last, "{out}"); - assert_eq!(events[last]["event"], "command_finished"); - assert_eq!(events[last]["exit_code"], 1); - assert_eq!(harness.seen_env.borrow().len(), 0); - } - } - - fn overrides_consumer(name: &str) -> Harness { - let harness = umbrella_clean(name); - harness.write_source( - "MODULE.bazel", - &format!( - "module(name = \"demo\")\n\nbazel_dep(name = \"rules_dx\", version = \"{}\")\n", - dx_adopt::DX_VERSION - ), - ); - harness - } - - #[test] - fn check_fails_on_stale_overrides_without_launching_bazel() { - let harness = overrides_consumer("umbrella-check-overrides-stale"); - dx_adopt::apply_overrides(&harness.workspace).expect("applies"); - harness.write_source( - "third_party/rules_dx/rules_rust.clippy_metadata_inputs.patch", - "corrupt\n", - ); - let (code, out, err) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 1, "{out}{err}"); - assert!( - json_events(&out) - .iter() - .any(|event| event["code"] == "overrides_stale"), - "{out}" - ); - assert_eq!(harness.seen_env.borrow().len(), 0); - } - - #[test] - fn check_notes_unadopted_overrides_and_continues() { - let harness = overrides_consumer("umbrella-check-overrides-warn"); - let (_, out, err) = harness.run(&["check", "--output=text"]); - assert!(err.contains("dx: overrides: "), "{out}{err}"); - assert!(!out.contains("overrides_stale"), "{out}{err}"); - assert!(!err.contains("overrides_stale"), "{out}{err}"); - } - - #[test] - fn check_is_silent_about_overrides_without_a_rules_dx_dep() { - let harness = umbrella_clean("umbrella-check-overrides-na"); - let (_, out, err) = harness.run(&["check", "--output=text"]); - assert!(!err.contains("overrides"), "{out}{err}"); - } - - #[test] - fn check_ignores_source_named_target_committed_on_main() { - let harness = umbrella_clean("umbrella-check-source-target"); - let git = |args: &[&str]| { - dx_process::capture::run_checked( - dx_process::capture::git(&harness.workspace) - .args(["-c", "user.name=t", "-c", "user.email=t@t"]) - .args(args), - dx_process::capture::DEFAULT_TIMEOUT, - ) - .unwrap_or_else(|error| panic!("{error}")); - }; - git(&["init", "-q", "-b", "main"]); - harness.write_source("src/x/target/Foo.java", "class Foo {}"); - git(&["add", "-A"]); - git(&["commit", "-q", "-m", "init"]); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("Running format analysis"), "{out}"); - } - - #[test] - fn check_reports_a_git_failure_from_the_build_output_check() { - let harness = umbrella_clean("umbrella-check-git-failed"); - let git = |args: &[&str]| { - dx_process::capture::run_checked( - dx_process::capture::git(&harness.workspace).args(args), - dx_process::capture::DEFAULT_TIMEOUT, - ) - .unwrap_or_else(|error| panic!("{error}")); - }; - git(&["init", "-q"]); - std::fs::write(harness.workspace.join(".git/index"), b"garbage").expect("index"); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 2, "{out}{err}"); - assert!(err.contains("build-output check failed"), "{out}{err}"); - assert_eq!(harness.seen_env.borrow().len(), 0); - } - - #[test] - fn check_clean_runs_every_phase_in_order() { - let harness = umbrella_clean("umbrella-check-clean"); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 0, "{out}{err}"); - let format = out - .find("Running format analysis for //...") - .expect("format"); - let lint = out.find("Running lint analysis for //...").expect("lint"); - let typecheck = out - .find("Running typecheck analysis for //...") - .expect("typecheck"); - let generate = out.find("Running generate for //...").expect("generate"); - assert!( - generate < format && format < lint && lint < typecheck, - "{out}" - ); - assert_eq!(err, "no findings in 0 target(s)\n".repeat(3), "{err}"); - assert_eq!( - harness.seen_env.borrow().len(), - 4, - "one Bazel launch per phase" - ); - } - - #[test] - fn check_json_sums_action_counts_across_phases() { - let mut harness = umbrella_clean("umbrella-check-actions"); - harness.build_metrics = Some(build_metrics_line(10, 9, 2, 3, 5)); - let (code, out, err) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let phases: Vec<&serde_json::Value> = events - .iter() - .filter(|event| event["event"] == "phase_finished" && event.get("actions").is_some()) - .collect(); - assert_eq!(phases.len(), 3, "{out}"); - for phase in &phases { - assert_eq!( - phase["actions"], - serde_json::json!({"created": 10, "executed": 4, "cache_hits": {"disk": 2, "remote": 3, "local": 5}}), - "{phase}" - ); - } - assert_eq!( - finished_actions(&out), - Some( - serde_json::json!({"created": 30, "executed": 12, "cache_hits": {"disk": 6, "remote": 9, "local": 15}}) - ) - ); - } - - #[test] - fn check_json_brackets_phase_lifecycles() { - let harness = umbrella_clean("umbrella-check-json"); - let (code, out, err) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - assert_eq!(events.first().expect("first")["event"], "command_started"); - assert_eq!(events.first().expect("first")["command"], "check"); - assert_eq!(events.last().expect("last")["event"], "command_finished"); - assert_eq!(events.last().expect("last")["exit_code"], 0); - assert_eq!(events_of_kind(&events, "command_started").len(), 1); - assert_eq!(events_of_kind(&events, "command_finished").len(), 1); - let boundaries: Vec<(&str, &str)> = events - .iter() - .filter(|event| event["event"] == "phase_started" || event["event"] == "phase_finished") - .map(|event| { - ( - event["event"].as_str().expect("event"), - event["phase"].as_str().expect("phase"), - ) - }) - .collect(); - assert_eq!( - boundaries, - vec![ - ("phase_started", "generate"), - ("phase_finished", "generate"), - ("phase_started", "format"), - ("phase_finished", "format"), - ("phase_started", "lint"), - ("phase_finished", "lint"), - ("phase_started", "typecheck"), - ("phase_finished", "typecheck"), - ] - ); - for event in &events[1..events.len() - 1] { - assert_eq!(event["command"], "check", "{event}"); - let phase = event["phase"].as_str().expect("phase"); - assert_eq!( - event["correlation"], - serde_json::json!(format!("check/{phase}")), - "{event}" - ); - assert_eq!(event["schema"]["major"], 4, "{event}"); - } - } - - fn generate_failed_harness(name: &str) -> Harness { - let mut harness = umbrella_findings(name); - harness.bazel_code = 1; - harness.intended = Some(intended_witness_failed( - "check", - r#"{"language":"cc","message":"boom"}"#, - )); - harness - } - - #[test] - fn check_stops_after_generate_failure() { - let harness = generate_failed_harness("umbrella-generate-failed"); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains( - "dx: phases: generate failed, format skipped, lint skipped, typecheck skipped" - ), - "{err}" - ); - assert_eq!(harness.seen_env.borrow().len(), 1, "only generate launches"); - } - - #[test] - fn check_json_marks_phases_skipped_after_generate_failure() { - let harness = generate_failed_harness("umbrella-generate-failed-json"); - let (code, out, _) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let skipped = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == "umbrella_phase_skipped") - .count(); - assert_eq!(skipped, 3, "{out}"); - } - - #[test] - fn check_runs_every_phase_after_ordinary_findings() { - let harness = umbrella_findings("umbrella-continue"); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!(out.contains("Running format analysis for //..."), "{out}"); - assert!(out.contains("Running lint analysis for //..."), "{out}"); - assert!( - out.contains("Running typecheck analysis for //..."), - "{out}" - ); - assert!(out.contains("Running generate for //..."), "{out}"); - assert!( - err.contains( - "dx: phases: generate failed, format findings, lint findings, typecheck findings" - ), - "{err}" - ); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"x = 1\n", - "check mode never mutates" - ); - assert_eq!(harness.seen_env.borrow().len(), 4, "every phase launches"); - } - - #[test] - fn check_json_reports_every_phase_after_findings() { - let harness = umbrella_findings("umbrella-continue-json"); - let (code, out, _) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - assert_eq!(events.first().expect("first")["command"], "check"); - assert_eq!(events.last().expect("last")["event"], "command_finished"); - assert_eq!(events.last().expect("last")["exit_code"], 1); - assert_eq!(events_of_kind(&events, "command_started").len(), 1); - assert_eq!(events_of_kind(&events, "command_finished").len(), 1); - let started: Vec<&str> = events_of_kind(&events, "phase_started") - .iter() - .map(|event| event["phase"].as_str().expect("phase")) - .collect(); - assert_eq!(started, vec!["generate", "format", "lint", "typecheck"]); - let format_finished = events_of_kind(&events, "phase_finished") - .into_iter() - .find(|event| event["phase"] == "format") - .expect("format finished"); - assert_eq!(format_finished["exit_code"], 1); - assert_eq!(format_finished["correlation"], "check/format"); - let findings: Vec<&str> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == "umbrella_phase_findings") - .map(|notice| notice["correlation"].as_str().expect("correlation")) - .collect(); - assert_eq!( - findings, - vec!["check/format", "check/lint", "check/typecheck"] - ); - } - - #[test] - fn check_without_apply_stays_check_mode() { - let harness = umbrella_findings("umbrella-fix-check"); - let (code, out, _) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("Running format analysis for //..."), "{out}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"x = 1\n", - "forced check mode never mutates" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 4, - "every phase runs after findings" - ); - } - - #[test] - fn fix_applies_generate_mutation_after_clean_quality() { - let mut harness = Harness::new("umbrella-fix"); - harness.write_source("src/a.py", "x = 1\n"); - harness.write_source("rust/tests/fixtures/hello/BUILD.bazel", "xyz\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![]), - ); - harness.intended = Some(intended_witness( - "default", - true, - &intended_modify("rust/tests/fixtures/hello/BUILD.bazel", b"abc\n", b"xyz\n"), - "", - )); - let staged = harness.staged_artifacts(); - let (code, out, err) = fix_apply_with_staged_verify( - &harness, - staged, - Some(intended_witness("check", true, "", "")), - 4, - &["check", "--apply", "--output=text"], - ); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("Modified rust/tests/fixtures/hello/BUILD.bazel"), - "{out}" - ); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"x = 1\n" - ); - assert_eq!( - out.matches("Running generate").count(), - 1, - "only quality writes rerun generate: {out}" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 5, - "four apply launches plus one verification launch of generate" - ); - } - - #[test] - fn fix_default_checks_without_writing() { - let harness = umbrella_findings("umbrella-fix-default"); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("Running format analysis for //..."), "{out}"); - assert!(out.contains("Running lint analysis"), "{out}"); - assert!(out.contains("Running typecheck analysis"), "{out}"); - assert!(out.contains("Running generate"), "{out}"); - assert!( - err.contains( - "dx: phases: generate failed, format findings, lint findings, typecheck findings" - ), - "{err}" - ); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"x = 1\n", - "default fix never mutates" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 4, - "every phase runs after findings" - ); - } - - #[test] - fn fix_apply_verifies_quality_apply_with_clean_recheck() { - let mut harness = Harness::new("umbrella-fix-verify"); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - let staged = harness.staged_artifacts(); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - let verify_files: Vec<(PathBuf, Vec)> = staged - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - let (code, out, err) = fix_apply_format_edit_then_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=text"], - ); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "the apply pass wrote the validated edit" - ); - assert!(out.contains("Applied 1 file(s)."), "{out}"); - assert!( - !err.contains("Not applied"), - "later phases see the written file and offer no edit: {err}" - ); - assert_eq!( - out.matches("Running format analysis for //...").count(), - 1, - "a clean verification stays silent: {out}" - ); - assert_eq!( - out.matches("Running generate").count(), - 2, - "format wrote a file, so generate runs again after typecheck: {out}" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 7, - "five apply launches plus generate and format verification" - ); - } - - #[test] - fn fix_apply_clean_runs_no_verification() { - let mut harness = umbrella_clean("umbrella-fix-clean-no-verify"); - harness.intended = Some(intended_witness("default", true, "", "")); - let (code, out, err) = harness.run(&["check", "--apply", "--output=text"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - out.matches("Running format analysis for //...").count(), - 1, - "{out}" - ); - assert_eq!(out.matches("Running generate").count(), 1, "{out}"); - assert_eq!( - harness.seen_env.borrow().len(), - 4, - "four apply launches and no verification" - ); - } - - #[test] - fn fix_apply_json_clean_emits_no_verify_events() { - let mut harness = umbrella_clean("umbrella-fix-clean-no-verify-json"); - harness.intended = Some(intended_witness("default", true, "", "")); - let (code, out, err) = harness.run(&["check", "--apply", "--output=json"]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - assert!( - events.iter().all(|event| !event["correlation"] - .as_str() - .is_some_and(|correlation| correlation.starts_with("verify/"))), - "{out}" - ); - assert_eq!( - events.last().expect("last")["exit_code"], - serde_json::json!(0) - ); - assert_eq!(harness.seen_env.borrow().len(), 4); - } - - #[test] - fn check_apply_unsafe_reaches_the_quality_phases() { - let mut harness = umbrella_clean("umbrella-unsafe"); - harness.intended = Some(intended_witness("default", true, "", "")); - let (code, out, err) = harness.run(&["check", "--apply", "--unsafe", "--output=json"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - json_events(&out)[0]["unsafe_fixes"], - serde_json::json!(true) - ); - let builds = harness.build_argv.borrow(); - let unsafe_builds: Vec<&Vec> = builds - .iter() - .filter(|argv| { - argv.iter() - .any(|arg| arg == "--aspects_parameters=unsafe_fixes=true") - }) - .collect(); - assert_eq!(unsafe_builds.len(), 3, "{builds:?}"); - assert!( - unsafe_builds - .iter() - .all(|argv| argv.iter().any(|arg| arg.starts_with("--aspects="))) - ); - } - - #[test] - fn fix_apply_fails_when_verification_finds_drift() { - let mut harness = Harness::new("umbrella-fix-verify-fails"); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - let staged = harness.staged_artifacts(); - let drift = harness.valid_result( - vec![Harness::diagnostic("unused", true)], - vec![harness.replacement(b"y")], - ); - let verify_files: Vec<(PathBuf, Vec)> = staged - .iter() - .map(|(path, _)| (path.clone(), drift.clone())) - .collect(); - let (code, out, err) = fix_apply_format_edit_then_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=text"], - ); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "only the apply pass writes; verification never does" - ); - assert!( - err.contains("dx: verification_failed: format failed verification after apply"), - "{err}" - ); - assert!( - !err.contains("verification skipped"), - "lint and typecheck touched nothing, so none is left to skip: {err}" - ); - assert!(out.contains("Running format analysis for //..."), "{out}"); - assert_eq!( - out.matches("Running format analysis for //...").count(), - 2, - "apply ran format, then verification re-ran it: {out}" - ); - assert_eq!( - out.matches("Running lint analysis for //...").count(), - 1, - "verification stops after the first failing phase: {out}" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 7, - "five apply launches plus generate and format verification" - ); - } - - #[test] - fn fix_apply_json_reports_verification_failure() { - let mut harness = Harness::new("umbrella-fix-verify-json"); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - let staged = harness.staged_artifacts(); - let drift = harness.valid_result( - vec![Harness::diagnostic("unused", true)], - vec![harness.replacement(b"y")], - ); - let verify_files: Vec<(PathBuf, Vec)> = staged - .iter() - .map(|(path, _)| (path.clone(), drift.clone())) - .collect(); - let (code, out, _) = fix_apply_format_edit_then_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=json"], - ); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - assert_eq!(events.first().expect("first")["command"], "check"); - assert_eq!(events.first().expect("first")["mode"], "default"); - let failure = event(&events, "error"); - assert_eq!(failure["code"], serde_json::json!("verification_failed")); - assert_eq!(failure["phase"], serde_json::json!("format")); - assert_eq!(failure["correlation"], serde_json::json!("check/format")); - assert_eq!( - events.last().expect("last")["exit_code"], - serde_json::json!(1) - ); - } - - #[test] - fn verification_failure_names_the_skipped_phases() { - let mut out = Vec::new(); - let mut err = Vec::new(); - announce_verification_failure( - &mut out, - &mut err, - OutputMode::Json, - "check", - "format", - &["lint", "typecheck"], - ) - .expect("announced"); - let err = String::from_utf8(err).expect("stderr"); - assert_eq!( - err, - "dx: verification_failed: format failed verification after apply; verification skipped for lint, typecheck\n" - ); - let events = json_events(&String::from_utf8(out).expect("stdout")); - assert_eq!(events[0]["code"], serde_json::json!("verification_failed")); - assert_eq!(events[0]["phase"], serde_json::json!("format")); - } - - #[test] - fn verification_collection_failure_is_shown_without_a_failed_phase() { - let mut verification = Verification { - failed_phase: None, - skipped: vec![], - apply_failed: vec![], - verified: vec![], - out: vec![], - err: vec![], - readable: true, - ended: None, - }; - let forwarded = forward_phase( - &mut verification.out, - &mut verification.err, - OutputMode::Json, - ("check", "check"), - "verify/lint", - (b"not an event\n", b""), - 0, - ); - assert_eq!(forwarded, Ok(false)); - verification.readable = false; - let (mut out, mut err) = (Vec::new(), Vec::new()); - deliver_verification(&mut out, &mut err, OutputMode::Json, "check", &verification) - .expect("delivered"); - let err = String::from_utf8(err).expect("utf8"); - assert!( - err.contains("dx: collection_failed: verify/lint wrote an unreadable event"), - "{err}" - ); - let out = String::from_utf8(out).expect("utf8"); - let events = json_events(&out); - let failures: Vec<_> = events.iter().filter(|e| e["event"] == "error").collect(); - assert_eq!(failures.len(), 1, "{out}"); - assert_eq!(failures[0]["code"], serde_json::json!("collection_failed")); - assert_eq!( - failures[0]["correlation"], - serde_json::json!("check/verify/lint") - ); - assert!(!out.contains("verification_failed"), "{out}"); - } - - #[test] - fn clean_verification_delivers_nothing() { - let verification = Verification { - failed_phase: None, - skipped: vec![], - apply_failed: vec![], - verified: vec![], - out: b"buffered out\n".to_vec(), - err: b"buffered err\n".to_vec(), - readable: true, - ended: None, - }; - let (mut out, mut err) = (Vec::new(), Vec::new()); - deliver_verification(&mut out, &mut err, OutputMode::Json, "check", &verification) - .expect("delivered"); - assert!(out.is_empty() && err.is_empty()); - } - - fn oscillating(harness: &Harness) -> Vec { - let mut result = quality_result::decode_validated(&harness.valid_result(vec![], vec![])) - .expect("valid result"); - result.convergence = quality_result::proto::Convergence::Oscillation as i32; - result.completed_rounds = 2; - quality_result::encode_validated(&result).expect("encode") - } - - const NOT_CONVERGED: &str = "dx: not_converged: //test:corpus did not converge: \ - OSCILLATION after 2 round(s) of lint-tool"; - - #[test] - fn check_and_fix_keep_going_after_a_diagnostic_free_unstable_phase() { - for words in [["check", "--output=text"], ["check", "--apply"]] { - let mut harness = Harness::new("umbrella-unstable"); - harness.write_source("src/a.py", "x = 1\n"); - let bytes = oscillating(&harness); - harness.results.insert("//test:corpus".to_owned(), bytes); - let (code, out, err) = harness.run(&words); - assert_eq!(code, 1, "{words:?}: {out}{err}"); - assert!(err.contains(NOT_CONVERGED), "{words:?}: {err}"); - assert!(out.contains("Running lint analysis"), "{words:?}: {out}"); - assert!(out.contains("Running generate"), "{words:?}: {out}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"x = 1\n", - "{words:?} keeps the source bytes" - ); - assert_eq!(harness.seen_env.borrow().len(), 4, "{words:?}"); - } - } - - #[test] - fn check_json_forwards_the_unstable_producer() { - let mut harness = Harness::new("umbrella-unstable-json"); - harness.write_source("src/a.py", "x = 1\n"); - let bytes = oscillating(&harness); - harness.results.insert("//test:corpus".to_owned(), bytes); - let (code, out, _) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let failure = events - .iter() - .find(|event| event["event"] == "error" && event["correlation"] != "check/generate") - .expect("error event"); - assert_eq!(failure["code"], "not_converged"); - assert_eq!( - failure["message"], - "//test:corpus did not converge: OSCILLATION after 2 round(s) of lint-tool" - ); - assert_eq!(events.last().expect("last")["exit_code"], 1); - } - - #[test] - fn fix_apply_verification_fails_on_an_unstable_recheck() { - let mut harness = Harness::new("umbrella-fix-verify-unstable"); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - let staged = harness.staged_artifacts(); - let unstable = - quality_result::decode_validated(&harness.result_full(vec![], vec![], vec![], vec![])) - .map(|mut result| { - result.convergence = quality_result::proto::Convergence::Oscillation as i32; - result.completed_rounds = 2; - quality_result::encode_validated(&result).expect("encode") - }) - .expect("valid result"); - let verify_files: Vec<(PathBuf, Vec)> = staged - .iter() - .map(|(path, _)| (path.clone(), unstable.clone())) - .collect(); - let (code, out, err) = fix_apply_format_edit_then_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=text"], - ); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "the stable apply pass wrote its edit" - ); - assert!(err.contains(NOT_CONVERGED), "{err}"); - assert!( - err.contains("dx: verification_failed: format failed verification after apply"), - "{err}" - ); - assert!( - !err.contains("verification skipped"), - "lint and typecheck touched nothing, so none is left to skip: {err}" - ); - } - - #[test] - fn fix_keeps_going_when_a_later_phase_goes_stale() { - let harness = umbrella_findings("umbrella-stale"); - let (code, out, _) = harness.run(&["check", "--apply", "--output=text"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("Running format analysis for //..."), "{out}"); - assert!(out.contains("Running lint analysis for //..."), "{out}"); - assert!(out.contains("Running typecheck analysis"), "{out}"); - assert!(out.contains("Running generate"), "{out}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "format applied before lint went stale" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 5, - "every phase launches once, then format is re-checked; stale lint is not" - ); - assert!( - harness - .seen_env - .borrow() - .iter() - .flatten() - .any(|(key, _)| key == GENERATE_ENV_INTENDED), - "generate dispatch ran" - ); - } - - /// A `check --apply` whose format edit goes stale before the apply pass. - fn refused_format(name: &str) -> Harness { - let mut harness = Harness::new(name); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - harness.write_source("src/a.py", "z = 2\n"); - harness - } - - #[test] - fn fix_apply_fails_when_format_refuses_a_stale_source() { - let harness = refused_format("umbrella-fix-refused"); - let (code, out, err) = harness.run(&["check", "--apply", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"z = 2\n" - ); - assert!( - err.contains("Not applied: src/a.py (stale_source)"), - "{err}" - ); - assert!(out.contains("Running lint analysis for //..."), "{out}"); - assert!(out.contains("Running typecheck analysis"), "{out}"); - assert!(out.contains("Running generate"), "{out}"); - assert_eq!( - harness.seen_env.borrow().len(), - 5, - "every phase launches once, then generate is re-checked; the refused format is not" - ); - - let harness = refused_format("umbrella-fix-refused-json"); - let (code, out, err) = harness.run(&["check", "--apply", "--output=json"]); - assert_eq!(code, 1, "{out}{err}"); - let events = json_events(&out); - let format_finished = events_of_kind(&events, "phase_finished") - .into_iter() - .find(|event| event["phase"] == "format") - .expect("format finished"); - assert_eq!(format_finished["exit_code"], 1, "{out}"); - assert_eq!( - format_finished["changes"]["refused"], - serde_json::json!(1), - "{out}" - ); - assert!( - events.iter().all(|event| !event["correlation"] - .as_str() - .is_some_and(|correlation| correlation.starts_with("verify/"))), - "{out}" - ); - assert_eq!( - events.last().expect("last")["exit_code"], - serde_json::json!(1) - ); - assert_eq!(harness.seen_env.borrow().len(), 5); - } - - #[test] - fn fix_runs_every_phase_after_a_tool_failure() { - let harness = umbrella_clean("umbrella-fix-tool-failure"); - let runner = FailFrom { - inner: harness.runner(), - from: 1, - to: Some(2), - code: 1, - launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!(runner.launches.get(), 4, "every phase launches"); - let events = json_events(&out); - let started: Vec<&str> = events_of_kind(&events, "phase_started") - .iter() - .map(|event| event["phase"].as_str().expect("phase")) - .collect(); - assert_eq!(started, vec!["generate", "format", "lint", "typecheck"]); - let failures: Vec<&str> = events_of_kind(&events, "error") - .iter() - .map(|event| event["correlation"].as_str().expect("correlation")) - .collect(); - assert_eq!(failures, vec!["check/format"], "{out}"); - assert!( - events_of_kind(&events, "notice") - .iter() - .all(|notice| notice["code"] != "umbrella_phase_skipped"), - "{out}" - ); - let finished = *events_of_kind(&events, "command_finished") - .last() - .expect("umbrella finished"); - assert_eq!(finished["exit_code"], serde_json::json!(1)); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - } - - /// Runs `fix --apply --output=` where lint (launch 2) exits 7 and - /// the verification launches see `verify_result`; only format edits. - fn fix_apply_lint_fails( - harness: &Harness, - verify_result: Vec, - output: &str, - ) -> (i32, String, String) { - let mut staged = staged_runner( - harness, - harness - .staged_artifacts() - .iter() - .map(|(path, _)| (path.clone(), verify_result.clone())) - .collect(), - Some(intended_witness("check", true, "", "")), - 5, - ); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - staged.clean_files = staged - .apply_files - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - staged.clean_from = 1; - let runner = FailFrom { - inner: staged, - from: 2, - to: Some(3), - code: 7, - launches: Cell::new(0), - }; - let output_flag = format!("--output={output}"); - let inv = umbrella_invocation(harness, &["check", "--apply", output_flag.as_str()]); - harness.execute_with(&inv, &runner) - } - - fn partial_failure_harness(name: &str) -> Harness { - let mut harness = Harness::new(name); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - harness - } - - #[test] - fn fix_apply_partial_failure_rechecks_passing_phases() { - let harness = partial_failure_harness("umbrella-fix-partial-recheck"); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - let (code, out, err) = fix_apply_lint_fails(&harness, clean, "json"); - let events = json_events(&out); - let lint_code = events - .iter() - .find(|event| event["event"] == "phase_finished" && event["phase"] == "lint") - .expect("lint finished")["exit_code"] - .as_i64() - .expect("lint code"); - assert_ne!(lint_code, 0, "{out}"); - assert_eq!(i64::from(code), lint_code, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "format applied before lint failed" - ); - let verify: Vec<&str> = events - .iter() - .filter_map(|event| event["correlation"].as_str()) - .filter(|correlation| correlation.starts_with("check/verify/")) - .collect(); - assert!(verify.contains(&"check/verify/format"), "{out}{err}"); - assert!(!verify.contains(&"check/verify/lint"), "{out}"); - assert!( - err.contains("verification passed after apply for generate, format") - && err.contains("failed during apply: lint"), - "{err}" - ); - assert_eq!( - events_of_kind(&events, "command_finished").len(), - 1, - "{out}" - ); - assert_eq!( - events.last().expect("last")["exit_code"], - serde_json::json!(lint_code) - ); - } - - #[test] - fn fix_apply_partial_failure_reports_a_failing_recheck() { - let harness = partial_failure_harness("umbrella-fix-partial-recheck-fails"); - let unstable = - quality_result::decode_validated(&harness.result_full(vec![], vec![], vec![], vec![])) - .map(|mut result| { - result.convergence = quality_result::proto::Convergence::Oscillation as i32; - result.completed_rounds = 2; - quality_result::encode_validated(&result).expect("encode") - }) - .expect("valid result"); - let (code, out, err) = fix_apply_lint_fails(&harness, unstable, "text"); - assert_eq!(code, 7, "the failed phase's code stays: {out}{err}"); - assert!( - err.contains("dx: verification_failed: format failed verification after apply"), - "{err}" - ); - } - - #[test] - fn fix_apply_does_not_recheck_the_failed_phase() { - let mut harness = Harness::new("umbrella-fix-failed-no-verify"); - harness.write_source("src/a.py", "x = 1\n"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement(b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - let staged = staged_runner( - &harness, - harness.staged_artifacts(), - Some(intended_witness("check", true, "", "")), - 5, - ); - let runner = FailFrom { - inner: staged, - from: 3, - to: Some(4), - code: 1, - launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "format applied before typecheck failed" - ); - assert_eq!( - runner.launches.get(), - 7, - "five phase launches plus the generate and format rechecks, never the failed typecheck" - ); - let events = json_events(&out); - assert!( - events.iter().all(|event| !event["correlation"] - .as_str() - .is_some_and(|correlation| correlation == "verify/typecheck")), - "{out}" - ); - assert_eq!( - events.last().expect("last")["exit_code"], - serde_json::json!(1) - ); - } - - #[test] - fn check_runs_every_phase_after_a_tool_failure() { - let harness = umbrella_clean("umbrella-check-tool-failure"); - let runner = FailFrom { - inner: harness.runner(), - from: 2, - to: Some(3), - code: 1, - launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", "--output=text"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!( - out.contains("Running typecheck analysis for //..."), - "{out}" - ); - assert!(out.contains("Running generate for //..."), "{out}"); - assert!( - err.contains("dx: analysis_failed: lint analysis failed"), - "{err}" - ); - assert!( - err.contains("dx: phases: generate clean, format clean, lint failed, typecheck clean"), - "{err}" - ); - assert_eq!(runner.launches.get(), 4, "every phase launches"); - } - - #[test] - fn check_runs_every_phase_after_a_lint_compile_error() { - let harness = umbrella_clean("umbrella-check-compile"); - let runner = compile_fail_at_lint(&harness); - let inv = umbrella_invocation(&harness, &["check", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!(runner.launches.get(), 4, "every phase launches"); - let events = json_events(&out); - assert!( - events_of_kind(&events, "notice") - .iter() - .all(|notice| notice["code"] != "umbrella_phase_skipped"), - "{out}" - ); - let errors = events_of_kind(&events, "error"); - assert!( - errors.iter().any(|event| event["code"] == "bazel_failed" - && event["cause"] == "bazel" - && event["correlation"] == "check/lint"), - "{out}" - ); - assert!(!out.contains("analysis_failed"), "{out}"); - let failures = events_of_kind(&events, "build_failure"); - assert_eq!(failures.len(), 1, "{out}"); - assert_eq!(failures[0]["correlation"], "check/lint"); - assert_eq!( - failures[0]["diagnostics"]["items"][0]["path"], - "app/src/main.rs" - ); - assert_eq!(events.last().expect("last")["exit_code"], 1, "{out}"); - } - - #[test] - fn fix_stops_after_a_lint_compile_error() { - let harness = umbrella_clean("umbrella-fix-compile"); - let runner = compile_fail_at_lint(&harness); - let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!(runner.launches.get(), 3, "typecheck never launches"); - let events = json_events(&out); - let skipped: Vec<&str> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == "umbrella_phase_skipped") - .filter_map(|notice| notice["message"].as_str()) - .collect(); - assert_eq!( - skipped, - vec!["typecheck skipped after lint failed to compile"] - ); - let errors = events_of_kind(&events, "error"); - assert!( - errors.iter().any( - |event| event["code"] == "bazel_failed" && event["correlation"] == "check/lint" - ), - "{out}" - ); - } - - #[test] - fn a_selection_stop_without_an_error_event_gains_one_before_the_closing_event() { - let started = br#"{"event":"phase_started","phase":"format"}"#; - let finished = br#"{"event":"phase_finished","phase":"format","exit_code":2}"#; - let mut out = [&started[..], b"\n", &finished[..], b"\n"].concat(); - let mut err = b"ERROR: no such target\nINFO: done\n".to_vec(); - explain_selection_stop(OutputMode::Json, "format", 2, (&mut out, &mut err)); - let lines: Vec = String::from_utf8(out) - .expect("utf8") - .lines() - .map(|line| serde_json::from_str(line).expect("event")) - .collect(); - assert_eq!(lines.len(), 3, "{lines:?}"); - assert_eq!(lines[1]["event"], "error"); - assert_eq!(lines[1]["code"], "invalid_scope"); - assert_eq!(lines[2]["event"], "phase_finished"); - let message = lines[1]["message"].as_str().expect("message"); - assert!(message.contains("INFO: done"), "{message}"); - assert!( - message.contains("pass a Bazel label or package"), - "{message}" - ); - } - - #[test] - fn a_selection_stop_that_already_has_an_error_event_is_left_alone() { - let error = br#"{"event":"error","code":"invalid_arguments","message":"x"}"#; - let mut out = [&error[..], b"\n"].concat(); - let before = out.clone(); - let mut err = Vec::new(); - explain_selection_stop(OutputMode::Json, "format", 2, (&mut out, &mut err)); - assert_eq!(out, before); - } - - #[test] - fn a_silent_text_selection_stop_writes_a_stderr_line() { - let mut out = Vec::new(); - let mut err = Vec::new(); - explain_selection_stop( - OutputMode::Text { quiet: false }, - "format", - 2, - (&mut out, &mut err), - ); - let text = String::from_utf8(err).expect("utf8"); - assert!( - text.starts_with("dx: invalid_scope: format rejected"), - "{text}" - ); - } - - #[test] - fn umbrella_stdout_report_is_rejected_pre_exec() { - let harness = umbrella_clean("umbrella-stdout-report"); - let (code, _, err) = harness.run(&["check", "--report=sarif=-", "--output=text"]); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("share one stdout document"), "{err}"); - assert!(harness.seen_env.borrow().is_empty(), "no phase launched"); - } - - #[test] - fn umbrella_unknown_report_is_rejected_pre_exec() { - let harness = umbrella_clean("umbrella-unknown-report"); - let (code, _, err) = harness.run(&["check", "--report=junit=out.xml"]); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("unsupported report format"), "{err}"); - assert!(harness.seen_env.borrow().is_empty(), "no phase launched"); - } - - #[test] - fn umbrella_sarif_merges_executed_phases_in_order() { - let harness = umbrella_clean("umbrella-sarif"); - let (code, out, err) = harness.run(&["check", "--output=json", "--report=sarif=out.sarif"]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let report = *events_of_kind(&events, "report") - .last() - .expect("umbrella report"); - assert_eq!(report["format"], serde_json::json!("sarif")); - assert_eq!(report["path"], serde_json::json!("out.sarif")); - assert_eq!( - report["results_complete"], - serde_json::json!(true), - "every phase ran and every capture parsed" - ); - assert_eq!( - event(&events, "command_finished")["results_complete"], - serde_json::json!(true) - ); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("out.sarif")).expect("sarif"), - ) - .expect("SARIF JSON"); - assert_eq!( - document["runs"].as_array().expect("runs").len(), - 2, - "lint and typecheck each contribute one run" - ); - assert_eq!( - document["runs"][0], document["runs"][1], - "both phases replay the shared clean fixture" - ); - - let (code, out, _) = harness.run(&["check", "--output=text", "--report=sarif=out.sarif"]); - assert_eq!(code, 0, "{out}"); - assert_eq!( - out.lines().last().expect("last line"), - "Wrote sarif report to out.sarif." - ); - } - - #[test] - fn fix_sarif_covers_only_executed_phases() { - let mut harness = umbrella_findings("umbrella-sarif-stop"); - harness.free_disk_bytes = Some(1 << 20); - let run = |output: &str| { - let runner = FailFrom { - inner: harness.runner(), - from: 2, - to: None, - code: 36, - launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", output, "--report=sarif=out.sarif"]); - harness.execute_with(&inv, &runner) - }; - let (code, out, err) = run("--output=json"); - assert_eq!(code, 36, "{out}{err}"); - let events = json_events(&out); - assert_eq!( - events_of_kind(&events, "report") - .last() - .expect("umbrella report")["results_complete"], - serde_json::json!(false), - "a run stopped at lint leaves the report incomplete" - ); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("out.sarif")).expect("sarif"), - ) - .expect("SARIF JSON"); - assert_eq!( - document["runs"].as_array().expect("runs").len(), - 0, - "no SARIF-capable phase finished, no runs" - ); - - let (code, _, err) = run("--diff"); - assert_eq!(code, 36); - assert!( - err.ends_with( - "Wrote sarif report to out.sarif (incomplete: generate failed, \ - lint failed, typecheck skipped).\n\ - dx: phases: generate failed, format findings, lint failed, typecheck skipped\n" - ), - "{err}" - ); - } - - #[test] - fn umbrella_sarif_skips_phase_that_could_not_render() { - let harness = umbrella_findings("umbrella-sarif-unrendered"); - let (code, out, err) = harness.run(&[ - "check", - "--apply", - "--output=text", - "--report=sarif=out.sarif", - ]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("failed to render SARIF report"), "{err}"); - assert!( - out.contains( - "Wrote sarif report to out.sarif \ - (incomplete: generate failed, lint failed, typecheck failed)." - ), - "{out}" - ); - let document: serde_json::Value = serde_json::from_slice( - &std::fs::read(harness.workspace.join("out.sarif")).expect("sarif"), - ) - .expect("SARIF JSON"); - assert!( - document["runs"].as_array().expect("runs").is_empty(), - "the phase that never rendered contributes no runs" - ); - } - - #[test] - fn umbrella_report_write_failure_fails() { - let harness = umbrella_clean("umbrella-report-fail"); - let (code, _, err) = harness.run(&[ - "check", - "--output=text", - "--report=sarif=missing-dir/out.sarif", - ]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("report_failed"), "{err}"); - - let (code, out, _) = harness.run(&[ - "check", - "--output=json", - "--report=sarif=missing-dir/out.sarif", - ]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("\"code\":\"report_failed\""), "{out}"); - } - - #[test] - fn umbrella_report_into_a_directory_ends_with_one_finished() { - let harness = umbrella_clean("umbrella-report-dir"); - std::fs::create_dir(harness.workspace.join("out.sarif")).expect("directory"); - let (code, out, _) = harness.run(&["check", "--output=json", "--report=sarif=out.sarif"]); - assert_eq!(code, 1, "{out}"); - let events = json_events(&out); - let finished = events - .iter() - .filter(|event| event["event"] == "command_finished") - .count(); - assert_eq!(finished, 1, "{out}"); - assert_eq!( - events.last().expect("last")["event"], - "command_finished", - "{out}" - ); - assert!( - events.iter().any(|event| event["code"] == "report_failed"), - "{out}" - ); - } - - #[test] - fn umbrella_failed_phase_and_failed_report_still_fails() { - let harness = umbrella_findings("umbrella-report-fail-stop"); - let (code, _, err) = harness.run(&[ - "check", - "--output=text", - "--report=sarif=missing-dir/out.sarif", - ]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("report_failed"), "{err}"); - } - - #[test] - fn fix_json_reports_umbrella_lifecycle() { - let harness = umbrella_clean("umbrella-fix-json"); - let (code, out, err) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - assert_eq!(events.first().expect("first")["command"], "check"); - assert_eq!( - events.first().expect("first")["mode"], - "check", - "bare fix checks without --apply" - ); - assert_eq!(events.last().expect("last")["exit_code"], 0); - } - - #[test] - fn umbrella_diff_renders_every_phase_patch() { - let harness = umbrella_findings("umbrella-diff"); - let (code, out, _) = harness.run(&["check", "--diff"]); - assert_eq!(code, 1, "{out}"); - assert!(out.contains("src/a.py"), "{out}"); - assert_eq!(harness.seen_env.borrow().len(), 4, "every phase launches"); - } - - #[test] - fn check_json_correlates_one_phase_start_per_phase() { - let harness = umbrella_clean("umbrella-phase-events"); - let (code, out, err) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - assert!(events_of_kind(&events, "operation").is_empty()); - let started = events_of_kind(&events, "phase_started"); - let described: Vec<(&str, &str, &str)> = started - .iter() - .map(|event| { - ( - event["command"].as_str().expect("command"), - event["phase"].as_str().expect("phase"), - event["mode"].as_str().expect("mode"), - ) - }) - .collect(); - assert_eq!( - described, - vec![ - ("check", "generate", "check"), - ("check", "format", "check"), - ("check", "lint", "check"), - ("check", "typecheck", "check") - ] - ); - for (index, phase) in ["generate", "format", "lint", "typecheck"] - .iter() - .enumerate() - { - assert_eq!( - started[index]["correlation"], - serde_json::json!(format!("check/{phase}")), - "{phase}" - ); - } - assert!( - events_of_kind(&events, "notice").is_empty(), - "nothing skipped, nothing to notice" - ); - } - - #[test] - fn check_skips_the_later_phases_after_bazel_runs_out_of_memory() { - let harness = umbrella_clean("umbrella-out-of-memory"); - let inv = umbrella_invocation(&harness, &["check", "--output=json"]); - let runner = FailFrom { - inner: harness.runner(), - from: 2, - to: None, - code: 33, - launches: Cell::new(0), - }; - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 33, "{out}{err}"); - let events = json_events(&out); - let errors = events_of_kind(&events, "error"); - assert_eq!(errors[0]["code"], serde_json::json!("server_out_of_memory")); - assert_eq!(errors[0]["cause"], serde_json::json!("host")); - let skipped = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == serde_json::json!("umbrella_phase_skipped")) - .count(); - assert!(skipped > 0, "{out}"); - } - - #[test] - fn check_reports_a_full_disk_once_and_skips_the_later_phases() { - let mut harness = umbrella_clean("umbrella-disk-full"); - harness.free_disk_bytes = Some(1 << 20); - let inv = umbrella_invocation(&harness, &["check", "--output=json"]); - let runner = FailFrom { - inner: harness.runner(), - from: 2, - to: None, - code: 36, - launches: Cell::new(0), - }; - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 36, "{out}{err}"); - assert!(err.contains("dx: disk_full: "), "{err}"); - assert!(!err.contains("analysis_failed"), "{err}"); - let events = json_events(&out); - let errors = events_of_kind(&events, "error"); - assert_eq!(errors.len(), 1, "{out}"); - assert_eq!(errors[0]["code"], serde_json::json!("disk_full")); - assert_eq!(errors[0]["cause"], serde_json::json!("host")); - assert_eq!(errors[0]["correlation"], serde_json::json!("check/lint")); - let skipped: Vec<&str> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == serde_json::json!("umbrella_phase_skipped")) - .filter_map(|notice| notice["message"].as_str()) - .collect(); - assert_eq!( - skipped, - vec!["typecheck skipped after lint failed to run its tools"] - ); - } - - #[test] - fn check_json_names_every_skipped_phase() { - let mut harness = umbrella_clean("umbrella-skip-events"); - harness.free_disk_bytes = Some(1 << 20); - let runner = FailFrom { - inner: harness.runner(), - from: 2, - to: None, - code: 36, - launches: Cell::new(0), - }; - let inv = umbrella_invocation( - &harness, - &["check", "--output=json", "--report=sarif=out.sarif"], - ); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 36, "{out}{err}"); - let events = json_events(&out); - let skipped: Vec<(&str, &str)> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] != "disk_low") - .map(|notice| { - ( - notice["code"].as_str().expect("code"), - notice["message"].as_str().expect("message"), - ) - }) - .collect(); - assert_eq!( - skipped, - vec![( - CODE_UMBRELLA_PHASE_SKIPPED, - "typecheck skipped after lint failed to run its tools" - )] - ); - for notice in events_of_kind(&events, "notice") - .into_iter() - .filter(|notice| notice["code"] == "umbrella_phase_skipped") - { - assert_eq!(notice["related_command"], serde_json::json!("check")); - assert!(notice["correlation"].is_string(), "{notice}"); - } - assert_eq!( - events_of_kind(&events, "report") - .last() - .expect("umbrella report")["results_complete"], - serde_json::json!(false) - ); - let finished = *events_of_kind(&events, "command_finished") - .last() - .expect("umbrella finished"); - assert_eq!(finished["exit_code"], serde_json::json!(36)); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - assert_eq!( - harness.seen_env.borrow().len(), - 3, - "unsafe work never starts" - ); - } - - #[test] - fn check_missing_capture_fails_the_run_and_keeps_valid_partial_runs() { - let (harness, code, out, err) = umbrella_with_broken_capture( - umbrella_clean("umbrella-missing-capture"), - Rewrite::Remove, - &["check", "--output=text", "--report=sarif=out.sarif"], - ); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - err.replace("no findings in 0 target(s)\n", ""), - "dx: collection_failed: lint wrote no sarif capture\n", - "{err}" - ); - assert_eq!( - out.lines().last().expect("last line"), - "Wrote sarif report to out.sarif (incomplete: lint wrote no sarif capture)." - ); - for phase in ["format", "lint", "typecheck", "generate"] { - let marker = match phase { - "generate" => "Running generate for //...", - _ => &format!("Running {phase} analysis for //..."), - }; - assert!(out.contains(marker), "{phase} ran: {out}"); - } - let runs = merged_runs(&harness); - assert_eq!( - runs.len(), - 1, - "the typecheck capture still lands in the merged document" - ); - assert!( - !capture_path(&harness, "lint", 2).exists(), - "the capture is deleted even when it was never written" - ); - } - - #[test] - fn check_missing_capture_reports_an_incomplete_json_document() { - let (harness, code, out, err) = umbrella_with_broken_capture( - umbrella_clean("umbrella-missing-capture-json"), - Rewrite::Remove, - &["check", "--output=json", "--report=sarif=out.sarif"], - ); - assert_eq!(code, 1, "{out}{err}"); - let events = json_events(&out); - let failure = event(&events, "error"); - assert_eq!(failure["code"], serde_json::json!(CODE_COLLECTION_FAILED)); - assert_eq!( - failure["message"], - serde_json::json!("lint wrote no sarif capture") - ); - assert_eq!(failure["phase"], serde_json::json!("lint")); - assert_eq!(failure["correlation"], serde_json::json!("check/lint")); - assert_eq!( - events_of_kind(&events, "report") - .last() - .expect("umbrella report")["results_complete"], - serde_json::json!(false) - ); - let finished = *events_of_kind(&events, "command_finished") - .last() - .expect("umbrella finished"); - assert_eq!(finished["exit_code"], serde_json::json!(1)); - assert_eq!(finished["results_complete"], serde_json::json!(false)); - assert_eq!(merged_runs(&harness).len(), 1, "{out}"); - } - - #[test] - fn check_unreadable_capture_fails_the_run() { - let (harness, code, out, err) = umbrella_with_broken_capture( - umbrella_clean("umbrella-unreadable-capture"), - Rewrite::Block, - &["check", "--output=text", "--report=sarif=out.sarif"], - ); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - err.replace("no findings in 0 target(s)\n", ""), - "dx: collection_failed: lint sarif capture is unreadable\n", - "{err}" - ); - assert!( - out.contains("(incomplete: lint sarif capture is unreadable)"), - "{out}" - ); - assert_eq!(merged_runs(&harness).len(), 1, "{out}"); - } - - #[test] - fn fix_apply_mode_shares_the_same_capture_collector() { - let mut harness = umbrella_clean("umbrella-fix-missing-capture"); - harness.intended = Some(intended_witness("default", true, "", "")); - let staged = harness.staged_artifacts(); - let runner = staged_runner( - &harness, - staged, - Some(intended_witness("check", true, "", "")), - 4, - ); - let capture = capture_path(&harness, "lint", 2); - let breaking = RewriteCapture { - inner: runner, - capture, - rewrite: Rewrite::Remove, - done: Cell::new(false), - }; - let inv = umbrella_invocation( - &harness, - &[ - "check", - "--apply", - "--output=text", - "--report=sarif=out.sarif", - ], - ); - let (code, out, err) = harness.execute_with(&inv, &breaking); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - err.replace("no findings in 0 target(s)\n", ""), - "dx: collection_failed: lint wrote no sarif capture\n", - "{err}" - ); - assert!( - out.contains("Running generate for //..."), - "apply mode still runs every phase: {out}" - ); - assert_eq!( - out.lines().last().expect("last line"), - "Wrote sarif report to out.sarif (incomplete: lint wrote no sarif capture)." - ); - assert_eq!(merged_runs(&harness).len(), 1, "{out}"); - } - - #[test] - fn check_malformed_capture_fails_the_run() { - for (name, payload) in [ - ("not json at all", &b"{not json"[..]), - ("an empty document", &b"{}"[..]), - ("a document without runs", &br#"{"version":"2.1.0"}"#[..]), - ( - "a document whose runs are not an array", - &br#"{"version":"2.1.0","runs":{}}"#[..], - ), - ( - "a run without a tool", - &br#"{"version":"2.1.0","runs":[{"nope":1}]}"#[..], - ), - ( - "a tool without a driver", - &br#"{"version":"2.1.0","runs":[{"tool":{}}]}"#[..], - ), - ( - "a driver without a name", - &br#"{"version":"2.1.0","runs":[{"tool":{"driver":{}}}]}"#[..], - ), - ("a bare JSON array", &b"[]"[..]), - ] { - let (harness, code, out, err) = umbrella_with_broken_capture( - umbrella_clean(&format!("umbrella-malformed-{name}")), - Rewrite::Bytes(payload), - &["check", "--output=text", "--report=sarif=out.sarif"], - ); - assert_eq!(code, 1, "{name}: {out}{err}"); - assert_eq!( - err.replace("no findings in 0 target(s)\n", ""), - "dx: collection_failed: lint sarif capture is malformed\n", - "{name}: {err}" - ); - assert_eq!(merged_runs(&harness).len(), 1, "{name}: {out}"); - } - } - - #[test] - fn check_merged_document_is_deterministic() { - let (harness, code, _, err) = umbrella_with_broken_capture( - umbrella_clean("umbrella-deterministic"), - Rewrite::Bytes(br#"{"version":"2.1.0","runs":[{"nope":1}]}"#), - &["check", "--output=text", "--report=sarif=out.sarif"], - ); - assert_eq!(code, 1, "{err}"); - let first = std::fs::read(harness.workspace.join("out.sarif")).expect("first"); - let (code, _, err) = harness.run(&["check", "--output=text", "--report=sarif=out.sarif"]); - assert_eq!(code, 0, "{err}"); - let complete = std::fs::read(harness.workspace.join("out.sarif")).expect("second"); - assert_eq!(merged_runs(&harness).len(), 2); - assert_ne!(first, complete, "the broken run never reaches the document"); - } - - #[test] - fn umbrella_broken_pipe_stdout_fails_with_141() { - struct BrokenPipe; - impl Write for BrokenPipe { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) - } - fn flush(&mut self) -> io::Result<()> { - Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) - } - } - - let harness = umbrella_clean("umbrella-broken-pipe"); - let inv = umbrella_invocation( - &harness, - &["check", "--output=text", "--report=sarif=out.sarif"], - ); - let runner = harness.runner(); - let mut out = BrokenPipe; - let mut err = Vec::new(); - assert_eq!(harness.run_streams(&inv, &runner, &mut out, &mut err), 141); - assert_eq!( - harness.seen_env.borrow().len(), - 1, - "a broken phase stream stops the run after the phase that filled it" - ); - assert!( - !harness.workspace.join("out.sarif").exists(), - "no report is announced or written after stdout breaks" - ); - } - - #[test] - fn umbrella_stdout_write_failure_is_operational() { - struct FailAfterOne { - seen: usize, - } - impl Write for FailAfterOne { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.seen += 1; - if self.seen == 1 { - return Ok(buf.len()); - } - Err(io::Error::other("boom")) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - let harness = umbrella_clean("umbrella-stdout-failure"); - let inv = umbrella_invocation(&harness, &["check", "--output=json"]); - let runner = harness.runner(); - let mut out = FailAfterOne { seen: 0 }; - let mut err = Vec::new(); - assert_eq!( - harness.run_streams(&inv, &runner, &mut out, &mut err), - 1, - "the first event lands, the phase events do not" - ); - assert_eq!( - harness.seen_env.borrow().len(), - 1, - "no later phase launched after stdout failed" - ); - assert_eq!(out.seen, 2, "nothing is written after the failed write"); - assert!( - err.is_empty(), - "a stdout failure is not a stderr diagnostic" - ); - } - - #[test] - fn collector_classifies_every_capture_state() { - let dir = temp_dir("umbrella-collector"); - let good = dir.path().join("good.sarif"); - let bad = dir.path().join("bad.sarif"); - let missing = dir.path().join("missing.sarif"); - std::fs::write( - &good, - dx_plan::reports::render_sarif( - &["lint-tool".to_owned()], - &[], - &std::collections::BTreeMap::new(), - true, - ) - .expect("render"), - ) - .expect("good capture"); - std::fs::write(&bad, b"{}").expect("bad capture"); - - let mut collector = PhaseCollector::default(); - for phase in UMBRELLA_PHASES { - collector.plan(phase, phase.name()); - } - collector.record_exit(0, 1, PhaseKind::Tool); - collector.require_capture(1, good.clone()); - collector.record_exit(1, 0, PhaseKind::Clean); - collector.require_capture(2, bad.clone()); - collector.record_exit(2, 0, PhaseKind::Clean); - collector.require_capture(3, missing.clone()); - collector.record_exit(3, 0, PhaseKind::Clean); - collector.skip_remaining(String::new()); - collector.collect(); - - assert_eq!( - collector.collection_failures(), - vec![ - ("lint", "sarif capture is malformed"), - ("typecheck", "wrote no sarif capture"), - ] - ); - assert_eq!( - collector.reasons(), - vec![ - "generate failed".to_owned(), - "lint sarif capture is malformed".to_owned(), - "typecheck wrote no sarif capture".to_owned(), - ] - ); - assert!(!collector.complete()); - let document: serde_json::Value = - serde_json::from_str(&collector.document()).expect("JSON"); - assert_eq!(document["runs"].as_array().expect("runs").len(), 1); - - for path in [&good, &bad, &missing] { - collector.cleanup(); - assert!(!path.exists(), "{path:?} deleted"); - } - } - - #[test] - fn collector_merges_a_complete_run_deterministically() { - let dir = temp_dir("umbrella-collector-complete"); - let captures: Vec = ["lint", "typecheck"] - .iter() - .map(|name| { - let path = dir.path().join(format!("{name}.sarif")); - std::fs::write( - &path, - dx_plan::reports::render_sarif( - &[format!("{name}-tool")], - &[], - &std::collections::BTreeMap::new(), - true, - ) - .expect("render"), - ) - .expect("capture"); - path - }) - .collect(); - - let mut collector = PhaseCollector::default(); - for (index, phase) in UMBRELLA_PHASES.iter().enumerate() { - collector.plan(*phase, phase.name()); - if let Some(path) = index.checked_sub(2).and_then(|at| captures.get(at)) { - collector.require_capture(index, path.clone()); - } - collector.record_exit(index, 0, PhaseKind::Clean); - } - collector.collect(); - assert!(collector.complete(), "{:?}", collector.reasons()); - assert!(collector.collection_failures().is_empty()); - assert!(collector.skipped().is_empty()); - assert_eq!(collector.reasons(), Vec::::new()); - let document = collector.document(); - assert_eq!(document, collector.document(), "same input, same bytes"); - let parsed: serde_json::Value = serde_json::from_str(&document).expect("JSON"); - let runs = parsed["runs"].as_array().expect("runs"); - assert_eq!(runs.len(), 2); - assert_eq!(runs[0]["tool"]["driver"]["name"], "lint-tool"); - assert_eq!(runs[1]["tool"]["driver"]["name"], "typecheck-tool"); - } - - #[test] - fn test_phase_kind_maps_each_bazel_exit() { - let cases = [ - (0, true, false, PhaseKind::Clean), - (3, true, false, PhaseKind::Findings), - (3, false, false, PhaseKind::Incomplete), - (1, false, false, PhaseKind::Incomplete), - (1, true, false, PhaseKind::Incomplete), - (2, false, false, PhaseKind::Selection), - (4, true, false, PhaseKind::Selection), - (8, false, false, PhaseKind::Cancelled), - (36, false, false, PhaseKind::Tool), - (36, false, true, PhaseKind::Host), - (33, false, false, PhaseKind::Host), - (141, true, false, PhaseKind::Tool), - (1, false, true, PhaseKind::Host), - (1, true, true, PhaseKind::Host), - (0, true, true, PhaseKind::Clean), - ]; - for (code, complete, host, kind) in cases { - assert_eq!( - test_phase_kind(code, complete, host), - kind, - "{code} {complete} {host}" - ); - } - } - - #[test] - fn test_phase_results_complete_follows_kind() { - let collect = |code: i32, kind: PhaseKind| { - let mut collector = PhaseCollector::default(); - for phase in UMBRELLA_PHASES { - collector.plan(phase, phase.name()); - } - collector.plan(Command::Test, "test"); - for index in 0..UMBRELLA_PHASES.len() { - collector.record_exit(index, 0, PhaseKind::Clean); - } - collector.record_exit(UMBRELLA_PHASES.len(), code, kind); - collector.skip_remaining(String::new()); - collector.collect(); - collector - }; - let findings = collect(3, PhaseKind::Findings); - assert!(findings.complete(), "{:?}", findings.reasons()); - assert!(findings.reasons().is_empty()); - assert_eq!(findings.with_findings(), vec!["test"]); - for code in [1, 3] { - let incomplete = collect(code, PhaseKind::Incomplete); - assert!(!incomplete.complete()); - assert_eq!(incomplete.reasons(), vec!["test failed".to_owned()]); - assert!(incomplete.with_findings().is_empty()); - } - } - - /// Runs the umbrella test phase against a harness and returns its outcome. - fn run_test_phase(harness: &Harness) -> (i32, PhaseKind) { - let inv = umbrella_invocation(harness, &["test", "--output=text"]); - let runner = harness.runner(); - let mut out = Vec::new(); - let mut err = Vec::new(); - let run = execute_test_phase(&inv, harness.env(&runner, &mut out, &mut err)); - assert_eq!(run.effects, PhaseEffects::default()); - (run.code, run.kind) - } - - #[test] - fn test_phase_reads_dx_test_completeness() { - const FAILING_XML: &str = r#""#; - let failed_line = |uri: String| { - test_result_full_line( - "//a:t", - 1, - 1, - 1, - "FAILED", - None, - None, - None, - &[(String::from("test.xml"), uri)], - ) - }; - let harness = Harness::new("umbrella-test-phase-findings"); - let uri = write_bep_artifact(&harness, "test.xml", FAILING_XML.as_bytes()); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![failed_line(uri)]), - ..harness - }; - assert_eq!(run_test_phase(&harness), (3, PhaseKind::Findings)); - - let harness = Harness::new("umbrella-test-phase-missing"); - let missing = format!("file://{}", harness.temp.join("absent.xml").display()); - let harness = Harness { - bazel_code: 3, - raw_bep: Some(vec![failed_line(missing)]), - ..harness - }; - assert_eq!(run_test_phase(&harness), (3, PhaseKind::Incomplete)); - - let harness = Harness { - bazel_code: 1, - ..Harness::new("umbrella-test-phase-build-failed") - }; - assert_eq!(run_test_phase(&harness), (1, PhaseKind::Incomplete)); - } - - #[test] - fn test_phase_reports_launch_failure_as_host() { - let harness = Harness { - io_error: true, - ..Harness::new("umbrella-test-phase-launch") - }; - assert_eq!(run_test_phase(&harness), (1, PhaseKind::Host)); - - let harness = Harness { - signalled: true, - ..Harness::new("umbrella-test-phase-signalled") - }; - assert_eq!(run_test_phase(&harness), (1, PhaseKind::Host)); - - let harness = Harness { - bazel_code: 1, - ..Harness::new("umbrella-test-phase-launch-build-failed") - }; - assert_eq!(run_test_phase(&harness), (1, PhaseKind::Incomplete)); - } - - #[test] - fn test_phase_reports_full_disk_as_host() { - let harness = Harness { - bazel_code: 36, - free_disk_bytes: Some(1 << 20), - ..Harness::new("umbrella-test-phase-disk-full") - }; - assert_eq!(run_test_phase(&harness), (36, PhaseKind::Host)); - } - - /// Runs `check` where format passes and lint reports findings. - fn check_with_failing_lint(name: &str, words: &[&str]) -> (Harness, i32, String, String) { - let harness = umbrella_clean(name); - let (artifact, _) = harness - .staged_artifacts() - .into_iter() - .next() - .expect("artifact"); - let findings = harness.valid_result( - vec![Harness::diagnostic("unused", true)], - vec![harness.replacement(b"y")], - ); - let staged = staged_runner( - &harness, - vec![(artifact, findings)], - harness.intended.clone(), - 1, - ); - let inv = umbrella_invocation(&harness, words); - let (code, out, err) = harness.execute_with(&inv, &staged); - (harness, code, out, err) - } - - #[test] - fn check_affected_hints_name_dx_check_apply_with_the_affected_flag() { - let harness = umbrella_findings("umbrella-affected-hint"); - harness.write_source("test/BUILD.bazel", ""); - let mut inv = umbrella_invocation(&harness, &["check", "--affected", "--output=text"]); - inv.affected = false; - inv.targets = vec!["//test:corpus".to_owned(), "//test:other".to_owned()]; - inv.affected_handoff = Some(dx_plan::args::AffectedHandoff::default()); - let (code, out, err) = harness.execute_with(&inv, &harness.runner()); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("file(s) need changes. Apply them with: dx check --apply --affected\n"), - "{err}" - ); - for stale in ["dx format --apply", "dx lint --apply", "//test:"] { - assert!(!err.contains(stale), "{stale}: {err}"); - } - } - - fn commits_harness(name: &str, config: &str, subject: &str) -> (Harness, Invocation) { - let harness = umbrella_clean(name); - let git = |args: &[&str]| { - let output = dx_process::capture::run_checked( - dx_process::capture::git(&harness.workspace) - .args(["-c", "user.name=t", "-c", "user.email=t@example.com"]) - .args(["-c", "commit.gpgsign=false"]) - .args(args), - dx_process::capture::DEFAULT_TIMEOUT, - ) - .unwrap_or_else(|error| panic!("{args:?}: {error}")); - String::from_utf8_lossy(&output.stdout).trim().to_owned() - }; - git(&["init", "-q"]); - git(&["commit", "-q", "--allow-empty", "-m", "base"]); - let base = git(&["rev-parse", "HEAD"]); - harness.write_source("dx.toml", config); - git(&["add", "dx.toml"]); - git(&["commit", "-q", "-m", subject]); - let mut inv = umbrella_invocation(&harness, &["check", "--affected", "--output=text"]); - inv.affected = false; - inv.targets = vec!["//test:corpus".to_owned()]; - inv.affected_handoff = Some(dx_plan::args::AffectedHandoff { - base: Some(base), - ..Default::default() - }); - (harness, inv) - } - - const COMMITS_ON: &str = "schema_version = 1\n[check]\ncommits = \"conventional\"\n"; - - #[test] - fn check_affected_reports_a_commit_that_is_not_conventional() { - let (harness, inv) = commits_harness("umbrella-commits-bad", COMMITS_ON, "update stuff"); - harness.write_source("test/BUILD.bazel", ""); - let (code, out, err) = harness.execute_with(&inv, &harness.runner()); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("update stuff: not a conventional commit"), - "{err}" - ); - } - - #[test] - fn check_affected_accepts_conventional_commits() { - let (harness, inv) = - commits_harness("umbrella-commits-good", COMMITS_ON, "fix: update stuff"); - harness.write_source("test/BUILD.bazel", ""); - let (code, out, err) = harness.execute_with(&inv, &harness.runner()); - assert_eq!(code, 0, "{out}{err}"); - assert!(!err.contains("conventional"), "{err}"); - } - - #[test] - fn check_affected_skips_the_commits_phase_without_the_key() { - let (harness, inv) = commits_harness( - "umbrella-commits-off", - "schema_version = 1\n", - "update stuff", - ); - harness.write_source("test/BUILD.bazel", ""); - let (code, out, err) = harness.execute_with(&inv, &harness.runner()); - assert_eq!(code, 0, "{out}{err}"); - assert!(!err.contains("conventional"), "{err}"); - } - - #[test] - fn check_affected_generate_runs_on_affected_packages() { - let harness = umbrella_clean("umbrella-affected-generate"); - harness.write_source("test/BUILD.bazel", ""); - let mut inv = umbrella_invocation(&harness, &["check", "--affected", "--output=text"]); - inv.affected = false; - inv.targets = vec!["//test:corpus".to_owned(), "//test:other".to_owned()]; - inv.affected_handoff = Some(dx_plan::args::AffectedHandoff::default()); - let (code, out, err) = harness.execute_with(&inv, &harness.runner()); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("Running generate for //test/..."), "{out}"); - assert!(!out.contains("Running generate for //..."), "{out}"); - let scopes: Vec = harness - .seen_env - .borrow() - .iter() - .flatten() - .filter(|(key, _)| key == dx_plan::plan::GENERATE_ENV_SCOPE) - .map(|(_, value)| value.clone()) - .collect(); - assert_eq!( - scopes, - vec![r#"[{"dirs":["test"],"element":"//test/..."}]"#.to_owned()], - "{out}" - ); - } - - #[test] - fn check_target_hints_name_dx_check_apply_with_the_user_patterns() { - let harness = umbrella_findings("umbrella-target-hint"); - let (code, out, err) = harness.run(&["check", "//test:corpus", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("file(s) need changes. Apply them with: dx check --apply //test:corpus\n"), - "{err}" - ); - assert!(!err.contains("dx format --apply"), "{err}"); - } - - #[test] - fn check_findings_phases_keep_their_findings_in_a_complete_report() { - let (harness, code, out, err) = check_with_failing_lint( - "umbrella-failed-lint", - &["check", "--output=json", "--report=sarif=out.sarif"], - ); - assert_eq!(code, 1, "{out}{err}"); - let events = json_events(&out); - let report = *events_of_kind(&events, "report") - .last() - .expect("umbrella report"); - assert_eq!(report["results_complete"], serde_json::json!(true)); - let finished = *events_of_kind(&events, "command_finished") - .last() - .expect("umbrella finished"); - assert_eq!(finished["exit_code"], serde_json::json!(1)); - assert_eq!(finished["results_complete"], serde_json::json!(true)); - let runs = merged_runs(&harness); - assert_eq!(runs.len(), 2, "lint and typecheck captures are retained"); - assert!( - runs[0]["results"] - .as_array() - .is_some_and(|results| !results.is_empty()), - "{runs:?}" - ); - let skipped: Vec<&str> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == "umbrella_phase_skipped") - .map(|notice| notice["correlation"].as_str().expect("correlation")) - .collect(); - assert!(skipped.is_empty(), "{skipped:?}"); - assert!( - events_of_kind(&events, "error").is_empty(), - "a findings failure is not a collection failure: {out}" - ); - } - - #[test] - fn check_failed_phase_with_a_failed_report_write_keeps_its_exit() { - let (_, code, out, err) = check_with_failing_lint( - "umbrella-failed-lint-write", - &[ - "check", - "--output=text", - "--report=sarif=missing-dir/out.sarif", - ], - ); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("report_failed"), "{err}"); - } - - #[test] - fn collector_keeps_what_a_failed_phase_wrote() { - let dir = temp_dir("umbrella-collector-failed"); - let good = dir.path().join("good.sarif"); - let bad = dir.path().join("bad.sarif"); - let missing = dir.path().join("missing.sarif"); - std::fs::write( - &good, - dx_plan::reports::render_sarif( - &["lint-tool".to_owned()], - &[], - &std::collections::BTreeMap::new(), - true, - ) - .expect("render"), - ) - .expect("good capture"); - std::fs::write(&bad, b"{").expect("bad capture"); - for (path, expected) in [ - (&good, Vec::new()), - (&bad, vec![("lint", "sarif capture is malformed")]), - (&missing, Vec::new()), - ] { - let mut collector = PhaseCollector::default(); - collector.plan(Command::Lint, "lint"); - collector.require_capture(0, path.clone()); - collector.record_exit(0, 1, PhaseKind::Tool); - collector.collect(); - assert_eq!(collector.collection_failures(), expected); - assert!(!collector.complete()); - let document: serde_json::Value = - serde_json::from_str(&collector.document()).expect("JSON"); - assert_eq!( - document["runs"].as_array().expect("runs").len(), - usize::from(path == &good) - ); - } - } - - #[test] - fn collector_ignores_a_capture_a_failed_phase_never_owed() { - let mut collector = PhaseCollector::default(); - for phase in UMBRELLA_PHASES { - collector.plan(phase, phase.name()); - } - collector.record_exit(0, 0, PhaseKind::Clean); - collector.require_capture(1, PathBuf::from("/nonexistent/format.sarif")); - collector.record_exit(1, 1, PhaseKind::Tool); - collector.skip_remaining(String::new()); - collector.collect(); - assert_eq!(collector.collection_failures(), Vec::new()); - assert_eq!( - collector.reasons(), - vec![ - "format failed".to_owned(), - "lint skipped".to_owned(), - "typecheck skipped".to_owned(), - ] - ); - assert_eq!(collector.skipped(), vec!["lint", "typecheck"]); - collector.cleanup(); - } - - #[test] - fn check_stop_after_findings_keeps_both_facts() { - let mut harness = umbrella_findings("umbrella-stop-after-findings"); - harness.free_disk_bytes = Some(1 << 20); - let runner = FailFrom { - inner: harness.runner(), - from: 3, - to: None, - code: 36, - launches: Cell::new(0), - }; - let inv = umbrella_invocation( - &harness, - &["check", "--output=text", "--report=sarif=out.sarif"], - ); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 36, "{out}{err}"); - assert!( - err.contains( - "dx: phases: generate failed, format findings, lint findings, typecheck failed" - ), - "{err}" - ); - assert!( - out.contains("(incomplete: generate failed, typecheck failed)"), - "{out}" - ); - assert_eq!(harness.seen_env.borrow().len(), 4, "every phase starts"); - assert_eq!( - merged_runs(&harness).len(), - 1, - "lint merges and typecheck never wrote a capture" - ); - } - - #[test] - fn check_later_clean_phases_never_clear_earlier_findings() { - let harness = umbrella_findings("umbrella-later-clean"); - let (artifact, _) = harness - .staged_artifacts() - .into_iter() - .next() - .expect("artifact"); - let clean = harness.valid_result(vec![], vec![]); - let witness = intended_witness("check", true, "", ""); - let mut staged = staged_runner(&harness, vec![(artifact, clean)], Some(witness), 2); - staged.apply_intended = Some(intended_witness("check", true, "", "")); - let inv = umbrella_invocation(&harness, &["check", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &staged); - assert_eq!(code, 1, "{out}{err}"); - let events = json_events(&out); - let findings: Vec<&str> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == "umbrella_phase_findings") - .map(|notice| notice["correlation"].as_str().expect("correlation")) - .collect(); - assert_eq!(findings, vec!["check/format"]); - let finished = *events_of_kind(&events, "command_finished") - .last() - .expect("umbrella finished"); - assert_eq!(finished["exit_code"], serde_json::json!(1)); - assert_eq!(finished["results_complete"], serde_json::json!(true)); - } - - #[test] - fn check_below_threshold_findings_keep_every_phase_clean() { - let mut harness = umbrella_clean("umbrella-below-threshold"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![Harness::diagnostic("unused", true)], vec![]), - ); - let (code, out, err) = harness.run(&["check", "--output=text", "--fail-on=error"]); - assert_eq!(code, 0, "{out}{err}"); - assert!(!err.contains("dx: phases"), "{err}"); - assert_eq!(harness.seen_env.borrow().len(), 4); - } - - #[test] - fn check_stops_on_a_rejected_scope_before_any_later_phase() { - let harness = umbrella_findings("umbrella-bad-scope"); - let (code, out, err) = harness.run(&["check", "--output=text", "no/such/dir"]); - assert_eq!(code, 2, "{out}{err}"); - assert!( - err.contains("dx: phases: generate failed, format skipped"), - "{err}" - ); - assert!(harness.seen_env.borrow().is_empty(), "{out}{err}"); - } - - #[test] - fn check_reports_a_generate_exit_without_changes_as_a_tool_failure() { - let mut harness = umbrella_clean("umbrella-generate-tool-failure"); - harness.generate_code = Some(1); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("generate failed") && !err.contains("generate findings"), - "{out}{err}" - ); - } - - #[test] - fn check_reports_a_generate_exit_with_changes_but_no_patch_as_a_tool_failure() { - let mut harness = umbrella_clean("umbrella-generate-no-patch"); - harness.generate_code = Some(1); - harness.intended = Some(intended_witness( - "check", - true, - &intended_modify("src/BUILD.bazel", b"old\n", b"new\n"), - "", - )); - let (code, out, err) = harness.run(&["check", "--output=text"]); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("generate failed") && !err.contains("generate findings"), - "{out}{err}" - ); - } - - #[test] - fn check_reports_generate_drift_first_and_runs_every_phase() { - let mut harness = umbrella_clean("umbrella-generate-drift"); - harness.generate_code = Some(1); - harness.patch = Some(b"--- a\n+++ b\n".to_vec()); - harness.intended = Some(intended_witness( - "check", - true, - &intended_modify("src/BUILD.bazel", b"old\n", b"new\n"), - "", - )); - let (code, out, err) = harness.run(&["check", "--output=json"]); - assert_eq!(code, 1, "{out}{err}"); - let events = json_events(&out); - let finished: Vec<(String, i64)> = events_of_kind(&events, "phase_finished") - .iter() - .map(|event| { - ( - event["phase"].as_str().expect("phase").to_owned(), - event["exit_code"].as_i64().expect("exit"), - ) - }) - .collect(); - assert_eq!( - finished, - [ - ("generate".to_owned(), 1), - ("format".to_owned(), 0), - ("lint".to_owned(), 0), - ("typecheck".to_owned(), 0) - ] - ); - let changes = events_of_kind(&events, "change"); - assert_eq!(changes.len(), 1, "{out}"); - assert_eq!(changes[0]["path"], "src/BUILD.bazel"); - assert_eq!(changes[0]["effect"], "proposed"); - assert!(events_of_kind(&events, "mutation").is_empty(), "{out}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"x = 1\n" - ); - assert!(!harness.workspace.join("src/BUILD.bazel").exists()); - let last = events.last().expect("last"); - assert_eq!(last["exit_code"], 1, "{out}"); - assert_eq!(last["results_complete"], true, "{out}"); - } - - /// Plays Gazelle for `check --apply`: the first generate launch writes - /// `fixed` to `build` and its apply witness, later generate launches - /// report a clean check, and quality launches fail to compile until - /// `build` lists `source`. - struct StaleBuild { - inner: R, - build: PathBuf, - fixed: Vec, - source: &'static str, - apply_witness: Vec, - bep_line: String, - generate_launches: Cell, - } - - impl Runner for StaleBuild { - fn free_disk_bytes(&self, path: &Path) -> Option { - self.inner.free_disk_bytes(path) - } - - fn run( - &self, - argv: &[String], - cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - let status = self.inner.run(argv, cwd, env)?; - if is_shard_probe(argv) { - return Ok(status); - } - if let Some(path) = env - .iter() - .find_map(|(key, value)| (*key == GENERATE_ENV_INTENDED).then_some(*value)) - { - let launch = self.generate_launches.get(); - self.generate_launches.set(launch + 1); - if launch == 0 { - std::fs::write(&self.build, &self.fixed).expect("BUILD"); - std::fs::write(path, &self.apply_witness).expect("witness"); - } else { - std::fs::write(path, intended_witness("check", true, "", "")).expect("witness"); - } - return Ok(status); - } - let build = std::fs::read_to_string(&self.build).unwrap_or_default(); - if build.contains(self.source) { - return Ok(status); - } - if let Some(bep) = argv - .iter() - .find_map(|arg| arg.strip_prefix("--build_event_json_file=")) - { - std::fs::write(bep, format!("{}\n{LAST_EVENT}", self.bep_line)).expect("BEP"); - } - Ok(ChildStatus::exited(1)) - } - } - - /// The phase names a JSON umbrella run started, in order. - fn started_phases(events: &[serde_json::Value]) -> Vec { - events_of_kind(events, "phase_started") - .iter() - .map(|event| event["phase"].as_str().expect("phase").to_owned()) - .collect() - } - - /// Every generate witness path the runner saw, in launch order. - fn intended_paths(harness: &Harness) -> Vec { - harness - .seen_env - .borrow() - .iter() - .flatten() - .filter(|(key, _)| key == GENERATE_ENV_INTENDED) - .map(|(_, value)| value.clone()) - .collect() - } - - #[test] - fn fix_apply_generates_srcs_before_quality_builds_a_new_source() { - let mut harness = umbrella_clean("umbrella-fix-new-source"); - let stale = b"rust_library(srcs = [\"lib.rs\"])\n"; - let fixed = b"rust_library(srcs = [\"foo.rs\", \"lib.rs\"])\n".to_vec(); - harness.write_source("src/foo.rs", "pub fn foo() {}\n"); - harness.write_source("src/BUILD.bazel", std::str::from_utf8(stale).expect("utf8")); - harness.intended = None; - let uri = stderr_file(&harness, "foo-rustc", RENDERED_E0308); - let runner = StaleBuild { - inner: harness.runner(), - build: harness.workspace.join("src/BUILD.bazel"), - fixed: fixed.clone(), - source: "foo.rs", - apply_witness: intended_witness( - "default", - true, - &intended_modify("src/BUILD.bazel", stale, &fixed), - "", - ), - bep_line: failed_action("//src:lib", "c1", "Rustc", "out/lib.rlib", &uri), - generate_launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/BUILD.bazel")).expect("BUILD"), - fixed - ); - let events = json_events(&out); - assert_eq!( - started_phases(&events), - vec!["generate", "format", "lint", "typecheck"], - "{out}" - ); - assert_eq!(runner.generate_launches.get(), 2, "apply plus its re-check"); - } - - #[test] - fn fix_apply_reruns_generate_only_after_a_quality_write() { - let harness = partial_failure_harness("umbrella-fix-rerun-json"); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - let verify_files: Vec<(PathBuf, Vec)> = harness - .staged_artifacts() - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - let (code, out, err) = fix_apply_format_edit_then_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=json"], - ); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - assert_eq!( - started_phases(&events), - vec!["generate", "format", "lint", "typecheck", "rerun/generate"], - "{out}" - ); - let rerun: Vec<&serde_json::Value> = events - .iter() - .filter(|event| event["correlation"] == "check/rerun/generate") - .collect(); - assert!(!rerun.is_empty(), "{out}"); - assert!( - rerun.iter().all(|event| event["phase"] == "rerun/generate"), - "{out}" - ); - assert!( - events_of_kind(&events, "notice") - .iter() - .all(|notice| notice["code"] != "umbrella_phase_skipped"), - "{out}" - ); - } - - #[test] - fn fix_apply_without_a_write_runs_generate_once() { - let mut clean = umbrella_clean("umbrella-no-rerun-clean"); - clean.intended = Some(intended_witness("default", true, "", "")); - let refused = refused_format("umbrella-no-rerun-refused"); - let findings = umbrella_findings("umbrella-no-rerun-fix"); - let checked = umbrella_findings("umbrella-no-rerun-check"); - for (harness, words) in [ - (&clean, &["check", "--apply", "--output=json"][..]), - (&refused, &["check", "--apply", "--output=json"][..]), - (&findings, &["check", "--output=json"][..]), - (&checked, &["check", "--output=json"][..]), - ] { - let (_, out, err) = harness.run(words); - let events = json_events(&out); - let started = started_phases(&events); - assert_eq!( - &started[..4], - ["generate", "format", "lint", "typecheck"], - "{words:?}: {out}{err}" - ); - assert!( - !started.iter().any(|phase| phase == "rerun/generate"), - "{words:?}: {out}" - ); - assert!(!out.contains("rerun/generate"), "{words:?}: {out}"); - } - } - - #[test] - fn check_apply_reruns_generate_after_a_lint_compile_error() { - let harness = partial_failure_harness("umbrella-fix-stop-no-rerun"); - let mut staged = staged_runner( - &harness, - harness.staged_artifacts(), - Some(intended_witness("check", true, "", "")), - 5, - ); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - staged.clean_files = staged - .apply_files - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - staged.clean_from = 1; - let uri = stderr_file(&harness, "lint-clippy", RENDERED_E0308); - let runner = CompileFailAt { - inner: staged, - at: 2, - bep_line: failed_action("//app:bin", "c1", "Clippy", "out/bin.clippy", &uri), - launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("src/a.py")).expect("source"), - b"y = 1\n", - "format applied before lint stopped the run" - ); - let events = json_events(&out); - assert_eq!( - started_phases(&events), - vec!["generate", "format", "lint", "rerun/generate"], - "{out}" - ); - let skipped: Vec<&str> = events_of_kind(&events, "notice") - .iter() - .filter(|notice| notice["code"] == "umbrella_phase_skipped") - .filter_map(|notice| notice["message"].as_str()) - .collect(); - assert_eq!( - skipped, - vec!["typecheck skipped after lint failed to compile"] - ); - assert_eq!(runner.launches.get(), 4, "{out}"); - } - - #[test] - fn fix_apply_verification_nonces_differ_from_the_rerun() { - let harness = partial_failure_harness("umbrella-fix-rerun-nonces"); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - let verify_files: Vec<(PathBuf, Vec)> = harness - .staged_artifacts() - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - let (code, out, err) = fix_apply_format_edit_then_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=text"], - ); - assert_eq!(code, 0, "{out}{err}"); - let paths = intended_paths(&harness); - assert_eq!( - paths.len(), - 3, - "generate, rerun and verify/generate: {paths:?}" - ); - let distinct: std::collections::BTreeSet<&String> = paths.iter().collect(); - assert_eq!(distinct.len(), paths.len(), "{paths:?}"); - } - - #[test] - fn fix_apply_failed_rerun_is_not_rechecked() { - let harness = partial_failure_harness("umbrella-fix-rerun-fails"); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - let mut staged = staged_runner( - &harness, - harness - .staged_artifacts() - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(), - Some(intended_witness("check", true, "", "")), - 5, - ); - staged.clean_files = staged - .apply_files - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - staged.clean_from = 1; - let runner = FailFrom { - inner: staged, - from: 4, - to: Some(5), - code: 1, - launches: Cell::new(0), - }; - let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=text"]); - let (code, out, err) = harness.execute_with(&inv, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains( - "verification passed after apply for format; not re-checked, \ - failed during apply: rerun/generate" - ), - "{err}" - ); - assert!(err.contains("rerun/generate failed"), "{err}"); - assert_eq!(runner.launches.get(), 6, "{out}{err}"); - } - - #[test] - fn phase_kind_stops_on_selection_host_cancel_or_failed_generate() { - for kind in [ - PhaseKind::Clean, - PhaseKind::Findings, - PhaseKind::NonConvergence, - PhaseKind::Tool, - PhaseKind::Incomplete, - PhaseKind::Report, - ] { - assert!(kind.continues(false), "{kind:?}"); - assert!(kind.continues(true), "{kind:?}"); - assert!(!kind.stop_reason().is_empty()); - } - for kind in [ - PhaseKind::Selection, - PhaseKind::Host, - PhaseKind::Cancelled, - PhaseKind::GenerateFailed, - ] { - assert!(!kind.continues(false), "{kind:?}"); - assert!(!kind.continues(true), "{kind:?}"); - assert!(!kind.stop_reason().is_empty()); - } - assert!(PhaseKind::Compile.continues(false)); - assert!(!PhaseKind::Compile.continues(true)); - assert_eq!(PhaseKind::Compile.stop_reason(), "failed to compile"); - } - - #[test] - fn parse_capture_keeps_only_well_formed_documents() { - let document = dx_plan::reports::render_sarif( - &["lint-tool".to_owned()], - &[], - &std::collections::BTreeMap::new(), - true, - ) - .expect("render"); - let parsed = parse_capture(document.as_bytes()).expect("round trip"); - assert_eq!(parsed["version"], serde_json::json!("2.1.0")); - assert_eq!(parse_capture(b" "), Err(CaptureState::Malformed)); - assert_eq!(parse_capture(b"null"), Err(CaptureState::Malformed)); - assert_eq!( - parse_capture(br#"{"runs":[],"$schema":"x"}"#), - Err(CaptureState::Malformed), - "a run needs a tool" - ); - assert_eq!( - parse_capture(br#"{"runs":[]}"#), - Err(CaptureState::Malformed), - "a document without a version is not SARIF 2.1.0" - ); - } - - fn graph_calls(harness: &Harness) -> usize { - harness - .query - .calls - .borrow() - .iter() - .filter(|argv| argv.last().map(String::as_str) == Some("kind('rule', //...)")) - .count() - } - - fn pkg_harness(name: &str) -> Harness { - let harness = umbrella_clean(name); - harness.write_source("pkg/BUILD.bazel", ""); - harness.write_source("pkg/a.py", "x = 1\n"); - harness - } - - #[test] - fn check_loads_the_rule_graph_once_across_phases() { - let harness = pkg_harness("umbrella-graph-once-check"); - harness.query.script_graph(&[ - graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), - graph_rule("//pkg:extra", &["//pkg:a.py"], &[]), - ]); - let (code, out, err) = harness.run(&["check", "--output=text", "pkg/a.py"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(harness.seen_env.borrow().len(), 4, "every phase ran"); - assert_eq!( - out.matches("for //pkg:extra //pkg:lib").count(), - 3, - "every analysis phase plans the same owners: {out}" - ); - assert!(out.contains("Running generate for //pkg/..."), "{out}"); - assert_eq!(graph_calls(&harness), 1, "one graph load for the run"); - } - - #[test] - fn fix_without_changes_loads_the_rule_graph_once() { - let harness = pkg_harness("umbrella-graph-once-fix"); - harness - .query - .script_graph(&[graph_rule("//pkg:lib", &["//pkg:a.py"], &[])]); - let (code, out, err) = harness.run(&["check", "--output=text", "pkg/a.py"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(graph_calls(&harness), 1, "default fix never writes"); - } - - #[test] - fn fix_apply_reloads_the_rule_graph_after_a_write() { - let mut harness = pkg_harness("umbrella-graph-reload"); - harness.results.insert( - "//test:corpus".to_owned(), - harness.valid_result(vec![], vec![harness.replacement_in("pkg/a.py", b"y")]), - ); - harness.intended = Some(intended_witness("default", true, "", "")); - harness - .query - .script_graph(&[graph_rule("//pkg:lib", &["//pkg:a.py"], &[])]); - harness.query.script_graph(&[ - graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), - graph_rule("//pkg:new", &["//pkg:a.py"], &[]), - ]); - harness.query.script_graph(&[ - graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), - graph_rule("//pkg:new", &["//pkg:a.py"], &[]), - ]); - harness.query.script_graph(&[ - graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), - graph_rule("//pkg:new", &["//pkg:a.py"], &[]), - ]); - let staged = harness.staged_artifacts(); - let clean = harness.result_full(vec![], vec![], vec![], vec![]); - let verify_files: Vec<(PathBuf, Vec)> = staged - .iter() - .map(|(path, _)| (path.clone(), clean.clone())) - .collect(); - let (_, out, err) = fix_apply_with_staged_verify( - &harness, - verify_files, - Some(intended_witness("check", true, "", "")), - 5, - &["check", "--apply", "--output=text", "pkg/a.py"], - ); - assert!( - graph_calls(&harness) >= 2, - "a phase that applied edits invalidates the graph: {out}{err}" - ); - assert!( - out.contains("//pkg:new"), - "later plans see the new owner: {out}" - ); - } -} +#[path = "umbrella_tests.rs"] +mod tests; diff --git a/cli/cli/src/exec/umbrella_tests.rs b/cli/cli/src/exec/umbrella_tests.rs new file mode 100644 index 0000000000..ccb4d0592f --- /dev/null +++ b/cli/cli/src/exec/umbrella_tests.rs @@ -0,0 +1,3418 @@ +use super::super::test_support::*; +use super::*; +use dx_plan::args::Invocation; +use dx_plan::plan::GENERATE_ENV_INTENDED; +use dx_process::{ChildStatus, Runner}; +use std::cell::{Cell, RefCell}; +use std::io::{self, Write}; +use std::path::Path; +use std::rc::Rc; + +fn setting_args(argv: &[String]) -> std::collections::BTreeSet { + argv.iter() + .filter(|arg| arg.starts_with("--@")) + .cloned() + .collect() +} + +#[test] +fn umbrella_phases_and_test_share_the_bazel_settings() { + let resolved = dx_plan::resolve::ResolvedScope { + scope: dx_process::Scope::Repository, + targets: Vec::new(), + excluded: Vec::new(), + kept: Vec::new(), + }; + let shared: std::collections::BTreeSet = dx_plan::plan::BAZEL_SETTINGS + .iter() + .map(ToString::to_string) + .collect(); + let mut argvs = Vec::new(); + for phase in UMBRELLA_PHASES { + let plan = if phase == Command::Generate { + dx_plan::plan::plan_generate(&resolved, &[], true, &[], Some("/tmp/dx.patch")) + } else { + dx_plan::plan::plan_build(phase, &resolved, &[], "/tmp/bep.json", &[]) + }; + argvs.push((format!("{phase:?}"), plan.expect("phase plan").argv)); + let probe = dx_plan::plan::plan_probe(phase, &resolved, &[], "/tmp/probe.json", &[], None) + .expect("probe plan"); + if let Some(probe) = probe { + argvs.push((format!("{phase:?} probe"), probe.argv)); + } + } + let docs = dx_plan::plan::plan_docs(&resolved, &[], "/tmp/bep.json", &[]).expect("docs plan"); + argvs.push(("docs".to_owned(), docs.argv)); + let test = dx_plan::plan::plan_workflow( + dx_plan::plan::WorkflowVerb::Test, + &resolved, + &[], + Some("/tmp/bep.json"), + Some(dx_plan::args::BuildMode::Dev), + &[], + ) + .expect("test plan"); + argvs.push(("test".to_owned(), test.argv)); + let build_mode = dx_plan::args::BuildMode::Dev; + let label = "//app:bin".to_owned(); + let planned = [ + ( + "run", + dx_plan::plan::plan_run(&label, &[], &[], build_mode, &[]), + ), + ( + "run build", + dx_plan::plan::plan_run_build(std::slice::from_ref(&label), &[], build_mode, &[]), + ), + ( + "deploy build", + dx_plan::plan::plan_deploy_build(&label, build_mode, &[]), + ), + ( + "deploy run", + dx_plan::plan::plan_deploy_run(&label, &[], build_mode, &[]), + ), + ]; + for (name, plan) in planned { + argvs.push((name.to_owned(), plan.expect("plan").argv)); + } + let plan = dx_plan::plan::plan_managed( + Command::Sync, + &dx_setup::SetupScope::Repository, + &[], + "/tmp/bep.json", + &[], + ) + .expect("managed plan"); + argvs.push(("sync".to_owned(), plan.argv)); + let selection = dx_plan::resolve::SelectionContext::for_test_execution( + dx_plan::args::BuildMode::Dev, + &[], + &[], + ); + let cquery = selection + .selection_argv("cquery", &[], "tests(//...)") + .expect("cquery argv"); + argvs.push(("cquery".to_owned(), cquery)); + for (name, argv) in &argvs { + assert_eq!(setting_args(argv), shared, "{name}: {argv:?}"); + } + let query = selection + .selection_argv("query", &[], "tests(//...)") + .expect("query argv"); + assert!(setting_args(&query).is_empty(), "{query:?}"); +} + +/// Names the capture one umbrella phase writes into the harness temp dir. +fn capture_path(harness: &Harness, phase: &str, index: u64) -> PathBuf { + harness.temp.join(format!( + "umbrella-{phase}-{}-{index}.sarif", + std::process::id() + )) +} + +/// Parses one umbrella invocation against a harness workspace. +fn umbrella_invocation(harness: &Harness, words: &[&str]) -> Invocation { + let parsed = invocation(words); + dx_plan::args::apply_here(&parsed, &harness.workspace, &harness.cwd).expect("resolve workspace") +} + +/// How one phase capture is rewritten once its phase has finished. +#[derive(Clone, Copy)] +enum Rewrite { + Remove, + Block, + Bytes(&'static [u8]), +} + +impl Rewrite { + fn apply(self, path: &Path) { + match self { + Rewrite::Remove => { + std::fs::remove_file(path).expect("remove capture"); + } + Rewrite::Block => { + std::fs::remove_file(path).expect("remove capture"); + std::fs::create_dir(path).expect("capture directory"); + } + Rewrite::Bytes(payload) => { + std::fs::write(path, payload).expect("rewrite capture"); + } + } + } +} + +/// Serves the inner runner but exits nonzero from one launch up to an optional end. +struct FailFrom { + inner: R, + from: usize, + to: Option, + code: i32, + launches: Cell, +} + +impl Runner for FailFrom { + fn free_disk_bytes(&self, path: &Path) -> Option { + self.inner.free_disk_bytes(path) + } + + fn run(&self, argv: &[String], cwd: &Path, env: &[(&str, &str)]) -> io::Result { + if is_shard_probe(argv) { + return self.inner.run(argv, cwd, env); + } + let launch = self.launches.get(); + self.launches.set(launch + 1); + let status = self.inner.run(argv, cwd, env)?; + if launch >= self.from && self.to.is_none_or(|to| launch < to) { + return Ok(ChildStatus::exited(self.code)); + } + Ok(status) + } +} + +/// Serves the inner runner but makes one launch a failed compile with exit 1. +struct CompileFailAt { + inner: R, + at: usize, + bep_line: String, + launches: Cell, +} + +impl Runner for CompileFailAt { + fn free_disk_bytes(&self, path: &Path) -> Option { + self.inner.free_disk_bytes(path) + } + + fn run(&self, argv: &[String], cwd: &Path, env: &[(&str, &str)]) -> io::Result { + if is_shard_probe(argv) { + return self.inner.run(argv, cwd, env); + } + let launch = self.launches.get(); + self.launches.set(launch + 1); + let status = self.inner.run(argv, cwd, env)?; + if launch != self.at { + return Ok(status); + } + if let Some(bep) = argv + .iter() + .find_map(|arg| arg.strip_prefix("--build_event_json_file=")) + { + let rest = std::fs::read_to_string(bep).unwrap_or_default(); + std::fs::write(bep, format!("{}\n{rest}", self.bep_line)).expect("BEP file"); + } + Ok(ChildStatus::exited(1)) + } +} + +fn compile_fail_at_lint(harness: &Harness) -> CompileFailAt { + let uri = stderr_file(harness, "lint-clippy", RENDERED_E0308); + CompileFailAt { + inner: harness.runner(), + at: 2, + bep_line: failed_action("//app:bin", "c1", "Clippy", "out/bin.clippy", &uri), + launches: Cell::new(0), + } +} + +/// Rewrites one phase capture once the phase that wrote it has finished. +struct RewriteCapture { + inner: R, + capture: PathBuf, + rewrite: Rewrite, + done: Cell, +} + +impl Runner for RewriteCapture { + fn run(&self, argv: &[String], cwd: &Path, env: &[(&str, &str)]) -> io::Result { + let status = self.inner.run(argv, cwd, env)?; + if !self.done.get() && self.capture.exists() { + self.done.set(true); + self.rewrite.apply(&self.capture); + } + Ok(status) + } +} + +/// Runs one umbrella command whose lint capture is rewritten mid-run. +fn umbrella_with_broken_capture( + harness: Harness, + rewrite: Rewrite, + words: &[&str], +) -> (Harness, i32, String, String) { + let capture = capture_path(&harness, "lint", 2); + let runner = RewriteCapture { + inner: harness.runner(), + capture, + rewrite, + done: Cell::new(false), + }; + let inv = umbrella_invocation(&harness, words); + let (code, out, err) = harness.execute_with(&inv, &runner); + (harness, code, out, err) +} + +/// Serves apply content for the first `verify_from` launches, then +/// verification content, so `check --apply` tests can stage what the +/// read-only re-check observes after the apply pass wrote sources. +/// Quality launches from the `clean_from`th quality launch on serve +/// `clean_files` instead, so a later phase does not re-offer an edit an +/// earlier phase already wrote. Generate launches never count toward it. +struct StagedRunner { + code: Option, + bep_lines: Vec, + apply_files: Vec<(PathBuf, Vec)>, + clean_files: Vec<(PathBuf, Vec)>, + clean_from: usize, + quality_launches: Cell, + verify_files: Vec<(PathBuf, Vec)>, + apply_intended: Option>, + verify_intended: Option>, + verify_from: usize, + launches: Cell, + #[allow(clippy::type_complexity)] + seen_env: Rc>>>, +} + +impl Runner for StagedRunner { + fn run(&self, argv: &[String], _cwd: &Path, env: &[(&str, &str)]) -> io::Result { + if is_shard_probe(argv) { + return Ok(ChildStatus { + code: Some(1), + signal: None, + }); + } + let launch = self.launches.get(); + self.launches.set(launch + 1); + let generate = env.iter().any(|(key, _)| *key == GENERATE_ENV_INTENDED); + let quality_launch = self.quality_launches.get(); + if !generate { + self.quality_launches.set(quality_launch + 1); + } + let (files, intended) = if launch >= self.verify_from { + (&self.verify_files, &self.verify_intended) + } else if !generate && quality_launch >= self.clean_from { + (&self.clean_files, &self.apply_intended) + } else { + (&self.apply_files, &self.apply_intended) + }; + for (path, bytes) in files { + std::fs::write(path, bytes).expect("stage artifact"); + } + if let Some(path) = env + .iter() + .find_map(|(key, value)| (*key == GENERATE_ENV_INTENDED).then_some(*value)) + { + if let Some(witness) = intended { + std::fs::write(path, witness).expect("stage witness"); + } + } + self.seen_env.borrow_mut().push( + env.iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + ); + if let Some(bep) = argv + .iter() + .find_map(|arg| arg.strip_prefix("--build_event_json_file=")) + { + std::fs::write(bep, self.bep_lines.join("\n")).expect("BEP file"); + } + Ok(ChildStatus { + code: self.code, + signal: None, + }) + } +} + +/// Builds one runner serving `harness` content for the apply pass and +/// `verify_files`/`verify_intended` from launch `verify_from` on. +fn staged_runner( + harness: &Harness, + verify_files: Vec<(PathBuf, Vec)>, + verify_intended: Option>, + verify_from: usize, +) -> StagedRunner { + let staged = harness.staged_artifacts(); + let files: Vec = staged + .iter() + .map(|(artifact, _)| { + serde_json::json!({"uri": format!("file://{}", artifact.to_string_lossy())}) + }) + .collect(); + StagedRunner { + code: Some(0), + bep_lines: vec![ + serde_json::json!({ + "id": {"namedSet": {"id": "0"}}, + "namedSetOfFiles": {"files": files}, + }) + .to_string(), + serde_json::json!({ + "id": {"targetCompleted": {"label": "//test:corpus"}}, + "completed": { + "success": true, + "outputGroup": [{"name": "dx_results", "fileSets": [{"id": "0"}]}], + }, + }) + .to_string(), + LAST_EVENT.to_owned(), + ], + apply_files: staged, + clean_files: Vec::new(), + clean_from: verify_from, + quality_launches: Cell::new(0), + verify_files, + apply_intended: harness.intended.clone(), + verify_intended, + verify_from, + launches: Cell::new(0), + seen_env: Rc::clone(&harness.seen_env), + } +} + +/// Runs one `check --apply` whose launches from `verify_from` observe +/// `verify_files` and `verify_intended` instead of the apply content. +fn fix_apply_with_staged_verify( + harness: &Harness, + verify_files: Vec<(PathBuf, Vec)>, + verify_intended: Option>, + verify_from: usize, + words: &[&str], +) -> (i32, String, String) { + let runner = staged_runner(harness, verify_files, verify_intended, verify_from); + let inv = umbrella_invocation(harness, words); + harness.execute_with(&inv, &runner) +} + +/// Like `fix_apply_with_staged_verify`, but only format (the first +/// quality launch) sees the harness edit; lint and typecheck see a clean +/// result, as real tools would once format wrote the file. +fn fix_apply_format_edit_then_verify( + harness: &Harness, + verify_files: Vec<(PathBuf, Vec)>, + verify_intended: Option>, + verify_from: usize, + words: &[&str], +) -> (i32, String, String) { + let mut runner = staged_runner(harness, verify_files, verify_intended, verify_from); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + runner.clean_files = runner + .apply_files + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + runner.clean_from = 1; + let inv = umbrella_invocation(harness, words); + harness.execute_with(&inv, &runner) +} + +/// Reads one merged SARIF document out of a harness workspace. +fn merged_runs(harness: &Harness) -> Vec { + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(harness.workspace.join("out.sarif")).expect("sarif")) + .expect("SARIF JSON"); + assert_eq!(document["version"], serde_json::json!("2.1.0")); + assert_eq!( + document["$schema"], + serde_json::json!("https://json.schemastore.org/sarif-2.1.0.json") + ); + document["runs"].as_array().expect("runs").to_vec() +} + +#[test] +fn check_rejects_build_output_and_names_the_file() { + let harness = umbrella_clean("umbrella-check-build-output"); + let git = |args: &[&str]| { + dx_process::capture::run_checked( + dx_process::capture::git(&harness.workspace).args(args), + dx_process::capture::DEFAULT_TIMEOUT, + ) + .unwrap_or_else(|error| panic!("{error}")); + }; + git(&["init", "-q"]); + harness.write_source("cli/target/debug/dx", "binary"); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("cli/target/debug/dx"), "{out}{err}"); + assert!(!out.contains("Running format"), "{out}"); + assert_eq!(harness.seen_env.borrow().len(), 0); +} + +fn dirty_build_output(name: &str) -> Harness { + let harness = umbrella_clean(name); + dx_process::capture::run_checked( + dx_process::capture::git(&harness.workspace).args(["init", "-q"]), + dx_process::capture::DEFAULT_TIMEOUT, + ) + .unwrap_or_else(|error| panic!("{error}")); + harness.write_source("cli/target/debug/dx", "binary"); + harness +} + +#[test] +fn lint_check_rejects_build_output_without_launching_bazel() { + let harness = dirty_build_output("umbrella-lint-build-output"); + let (code, out, err) = harness.run(&["lint", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("cli/target/debug/dx"), "{out}{err}"); + assert_eq!(harness.seen_env.borrow().len(), 0); +} + +#[test] +fn lint_apply_is_not_refused_for_build_output() { + let harness = dirty_build_output("umbrella-lint-apply-build-output"); + let (_, out, err) = harness.run(&["lint", "--apply", "--output=text"]); + assert!(!err.contains("build output in change"), "{out}{err}"); + assert!(!out.contains("build output in change"), "{out}{err}"); +} + +#[test] +fn check_and_fix_json_report_build_output_before_finishing() { + { + let command = "check"; + let harness = dirty_build_output(&format!("umbrella-{command}-json-build-output")); + let (code, out, err) = harness.run(&[command, "--output=json"]); + assert_eq!(code, 1, "{out}{err}"); + let events = json_events(&out); + let error = events + .iter() + .position(|event| event["code"] == "build_output_in_change") + .unwrap_or_else(|| panic!("{out}")); + let last = events.len() - 1; + assert!(error < last, "{out}"); + assert_eq!(events[last]["event"], "command_finished"); + assert_eq!(events[last]["exit_code"], 1); + assert_eq!(harness.seen_env.borrow().len(), 0); + } +} + +fn overrides_consumer(name: &str) -> Harness { + let harness = umbrella_clean(name); + harness.write_source( + "MODULE.bazel", + &format!( + "module(name = \"demo\")\n\nbazel_dep(name = \"rules_dx\", version = \"{}\")\n", + dx_adopt::DX_VERSION + ), + ); + harness +} + +#[test] +fn check_fails_on_stale_overrides_without_launching_bazel() { + let harness = overrides_consumer("umbrella-check-overrides-stale"); + dx_adopt::apply_overrides(&harness.workspace).expect("applies"); + harness.write_source( + "third_party/rules_dx/rules_rust.clippy_metadata_inputs.patch", + "corrupt\n", + ); + let (code, out, err) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 1, "{out}{err}"); + assert!( + json_events(&out) + .iter() + .any(|event| event["code"] == "overrides_stale"), + "{out}" + ); + assert_eq!(harness.seen_env.borrow().len(), 0); +} + +#[test] +fn check_notes_unadopted_overrides_and_continues() { + let harness = overrides_consumer("umbrella-check-overrides-warn"); + let (_, out, err) = harness.run(&["check", "--output=text"]); + assert!(err.contains("dx: overrides: "), "{out}{err}"); + assert!(!out.contains("overrides_stale"), "{out}{err}"); + assert!(!err.contains("overrides_stale"), "{out}{err}"); +} + +#[test] +fn check_is_silent_about_overrides_without_a_rules_dx_dep() { + let harness = umbrella_clean("umbrella-check-overrides-na"); + let (_, out, err) = harness.run(&["check", "--output=text"]); + assert!(!err.contains("overrides"), "{out}{err}"); +} + +#[test] +fn check_ignores_source_named_target_committed_on_main() { + let harness = umbrella_clean("umbrella-check-source-target"); + let git = |args: &[&str]| { + dx_process::capture::run_checked( + dx_process::capture::git(&harness.workspace) + .args(["-c", "user.name=t", "-c", "user.email=t@t"]) + .args(args), + dx_process::capture::DEFAULT_TIMEOUT, + ) + .unwrap_or_else(|error| panic!("{error}")); + }; + git(&["init", "-q", "-b", "main"]); + harness.write_source("src/x/target/Foo.java", "class Foo {}"); + git(&["add", "-A"]); + git(&["commit", "-q", "-m", "init"]); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("Running format analysis"), "{out}"); +} + +#[test] +fn check_reports_a_git_failure_from_the_build_output_check() { + let harness = umbrella_clean("umbrella-check-git-failed"); + let git = |args: &[&str]| { + dx_process::capture::run_checked( + dx_process::capture::git(&harness.workspace).args(args), + dx_process::capture::DEFAULT_TIMEOUT, + ) + .unwrap_or_else(|error| panic!("{error}")); + }; + git(&["init", "-q"]); + std::fs::write(harness.workspace.join(".git/index"), b"garbage").expect("index"); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 2, "{out}{err}"); + assert!(err.contains("build-output check failed"), "{out}{err}"); + assert_eq!(harness.seen_env.borrow().len(), 0); +} + +#[test] +fn check_clean_runs_every_phase_in_order() { + let harness = umbrella_clean("umbrella-check-clean"); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 0, "{out}{err}"); + let format = out + .find("Running format analysis for //...") + .expect("format"); + let lint = out.find("Running lint analysis for //...").expect("lint"); + let typecheck = out + .find("Running typecheck analysis for //...") + .expect("typecheck"); + let generate = out.find("Running generate for //...").expect("generate"); + assert!( + generate < format && format < lint && lint < typecheck, + "{out}" + ); + assert_eq!(err, "no findings in 0 target(s)\n".repeat(3), "{err}"); + assert_eq!( + harness.seen_env.borrow().len(), + 4, + "one Bazel launch per phase" + ); +} + +#[test] +fn check_json_sums_action_counts_across_phases() { + let mut harness = umbrella_clean("umbrella-check-actions"); + harness.build_metrics = Some(build_metrics_line(10, 9, 2, 3, 5)); + let (code, out, err) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let phases: Vec<&serde_json::Value> = events + .iter() + .filter(|event| event["event"] == "phase_finished" && event.get("actions").is_some()) + .collect(); + assert_eq!(phases.len(), 3, "{out}"); + for phase in &phases { + assert_eq!( + phase["actions"], + serde_json::json!({"created": 10, "executed": 4, "cache_hits": {"disk": 2, "remote": 3, "local": 5}}), + "{phase}" + ); + } + assert_eq!( + finished_actions(&out), + Some( + serde_json::json!({"created": 30, "executed": 12, "cache_hits": {"disk": 6, "remote": 9, "local": 15}}) + ) + ); +} + +#[test] +fn check_json_brackets_phase_lifecycles() { + let harness = umbrella_clean("umbrella-check-json"); + let (code, out, err) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + assert_eq!(events.first().expect("first")["event"], "command_started"); + assert_eq!(events.first().expect("first")["command"], "check"); + assert_eq!(events.last().expect("last")["event"], "command_finished"); + assert_eq!(events.last().expect("last")["exit_code"], 0); + assert_eq!(events_of_kind(&events, "command_started").len(), 1); + assert_eq!(events_of_kind(&events, "command_finished").len(), 1); + let boundaries: Vec<(&str, &str)> = events + .iter() + .filter(|event| event["event"] == "phase_started" || event["event"] == "phase_finished") + .map(|event| { + ( + event["event"].as_str().expect("event"), + event["phase"].as_str().expect("phase"), + ) + }) + .collect(); + assert_eq!( + boundaries, + vec![ + ("phase_started", "generate"), + ("phase_finished", "generate"), + ("phase_started", "format"), + ("phase_finished", "format"), + ("phase_started", "lint"), + ("phase_finished", "lint"), + ("phase_started", "typecheck"), + ("phase_finished", "typecheck"), + ] + ); + for event in &events[1..events.len() - 1] { + assert_eq!(event["command"], "check", "{event}"); + let phase = event["phase"].as_str().expect("phase"); + assert_eq!( + event["correlation"], + serde_json::json!(format!("check/{phase}")), + "{event}" + ); + assert_eq!(event["schema"]["major"], 4, "{event}"); + } +} + +fn generate_failed_harness(name: &str) -> Harness { + let mut harness = umbrella_findings(name); + harness.bazel_code = 1; + harness.intended = Some(intended_witness_failed( + "check", + r#"{"language":"cc","message":"boom"}"#, + )); + harness +} + +#[test] +fn check_stops_after_generate_failure() { + let harness = generate_failed_harness("umbrella-generate-failed"); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains( + "dx: phases: generate failed, format skipped, lint skipped, typecheck skipped" + ), + "{err}" + ); + assert_eq!(harness.seen_env.borrow().len(), 1, "only generate launches"); +} + +#[test] +fn check_json_marks_phases_skipped_after_generate_failure() { + let harness = generate_failed_harness("umbrella-generate-failed-json"); + let (code, out, _) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let skipped = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == "umbrella_phase_skipped") + .count(); + assert_eq!(skipped, 3, "{out}"); +} + +#[test] +fn check_runs_every_phase_after_ordinary_findings() { + let harness = umbrella_findings("umbrella-continue"); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!(out.contains("Running format analysis for //..."), "{out}"); + assert!(out.contains("Running lint analysis for //..."), "{out}"); + assert!( + out.contains("Running typecheck analysis for //..."), + "{out}" + ); + assert!(out.contains("Running generate for //..."), "{out}"); + assert!( + err.contains( + "dx: phases: generate failed, format findings, lint findings, typecheck findings" + ), + "{err}" + ); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"x = 1\n", + "check mode never mutates" + ); + assert_eq!(harness.seen_env.borrow().len(), 4, "every phase launches"); +} + +#[test] +fn check_json_reports_every_phase_after_findings() { + let harness = umbrella_findings("umbrella-continue-json"); + let (code, out, _) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + assert_eq!(events.first().expect("first")["command"], "check"); + assert_eq!(events.last().expect("last")["event"], "command_finished"); + assert_eq!(events.last().expect("last")["exit_code"], 1); + assert_eq!(events_of_kind(&events, "command_started").len(), 1); + assert_eq!(events_of_kind(&events, "command_finished").len(), 1); + let started: Vec<&str> = events_of_kind(&events, "phase_started") + .iter() + .map(|event| event["phase"].as_str().expect("phase")) + .collect(); + assert_eq!(started, vec!["generate", "format", "lint", "typecheck"]); + let format_finished = events_of_kind(&events, "phase_finished") + .into_iter() + .find(|event| event["phase"] == "format") + .expect("format finished"); + assert_eq!(format_finished["exit_code"], 1); + assert_eq!(format_finished["correlation"], "check/format"); + let findings: Vec<&str> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == "umbrella_phase_findings") + .map(|notice| notice["correlation"].as_str().expect("correlation")) + .collect(); + assert_eq!( + findings, + vec!["check/format", "check/lint", "check/typecheck"] + ); +} + +#[test] +fn check_without_apply_stays_check_mode() { + let harness = umbrella_findings("umbrella-fix-check"); + let (code, out, _) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("Running format analysis for //..."), "{out}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"x = 1\n", + "forced check mode never mutates" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 4, + "every phase runs after findings" + ); +} + +#[test] +fn fix_applies_generate_mutation_after_clean_quality() { + let mut harness = Harness::new("umbrella-fix"); + harness.write_source("src/a.py", "x = 1\n"); + harness.write_source("rust/tests/fixtures/hello/BUILD.bazel", "xyz\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![]), + ); + harness.intended = Some(intended_witness( + "default", + true, + &intended_modify("rust/tests/fixtures/hello/BUILD.bazel", b"abc\n", b"xyz\n"), + "", + )); + let staged = harness.staged_artifacts(); + let (code, out, err) = fix_apply_with_staged_verify( + &harness, + staged, + Some(intended_witness("check", true, "", "")), + 4, + &["check", "--apply", "--output=text"], + ); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("Modified rust/tests/fixtures/hello/BUILD.bazel"), + "{out}" + ); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"x = 1\n" + ); + assert_eq!( + out.matches("Running generate").count(), + 1, + "only quality writes rerun generate: {out}" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 5, + "four apply launches plus one verification launch of generate" + ); +} + +#[test] +fn fix_default_checks_without_writing() { + let harness = umbrella_findings("umbrella-fix-default"); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("Running format analysis for //..."), "{out}"); + assert!(out.contains("Running lint analysis"), "{out}"); + assert!(out.contains("Running typecheck analysis"), "{out}"); + assert!(out.contains("Running generate"), "{out}"); + assert!( + err.contains( + "dx: phases: generate failed, format findings, lint findings, typecheck findings" + ), + "{err}" + ); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"x = 1\n", + "default fix never mutates" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 4, + "every phase runs after findings" + ); +} + +#[test] +fn fix_apply_verifies_quality_apply_with_clean_recheck() { + let mut harness = Harness::new("umbrella-fix-verify"); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + let staged = harness.staged_artifacts(); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + let verify_files: Vec<(PathBuf, Vec)> = staged + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + let (code, out, err) = fix_apply_format_edit_then_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=text"], + ); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "the apply pass wrote the validated edit" + ); + assert!(out.contains("Applied 1 file(s)."), "{out}"); + assert!( + !err.contains("Not applied"), + "later phases see the written file and offer no edit: {err}" + ); + assert_eq!( + out.matches("Running format analysis for //...").count(), + 1, + "a clean verification stays silent: {out}" + ); + assert_eq!( + out.matches("Running generate").count(), + 2, + "format wrote a file, so generate runs again after typecheck: {out}" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 7, + "five apply launches plus generate and format verification" + ); +} + +#[test] +fn fix_apply_clean_runs_no_verification() { + let mut harness = umbrella_clean("umbrella-fix-clean-no-verify"); + harness.intended = Some(intended_witness("default", true, "", "")); + let (code, out, err) = harness.run(&["check", "--apply", "--output=text"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + out.matches("Running format analysis for //...").count(), + 1, + "{out}" + ); + assert_eq!(out.matches("Running generate").count(), 1, "{out}"); + assert_eq!( + harness.seen_env.borrow().len(), + 4, + "four apply launches and no verification" + ); +} + +#[test] +fn fix_apply_json_clean_emits_no_verify_events() { + let mut harness = umbrella_clean("umbrella-fix-clean-no-verify-json"); + harness.intended = Some(intended_witness("default", true, "", "")); + let (code, out, err) = harness.run(&["check", "--apply", "--output=json"]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + assert!( + events.iter().all(|event| !event["correlation"] + .as_str() + .is_some_and(|correlation| correlation.starts_with("verify/"))), + "{out}" + ); + assert_eq!( + events.last().expect("last")["exit_code"], + serde_json::json!(0) + ); + assert_eq!(harness.seen_env.borrow().len(), 4); +} + +#[test] +fn check_apply_unsafe_reaches_the_quality_phases() { + let mut harness = umbrella_clean("umbrella-unsafe"); + harness.intended = Some(intended_witness("default", true, "", "")); + let (code, out, err) = harness.run(&["check", "--apply", "--unsafe", "--output=json"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + json_events(&out)[0]["unsafe_fixes"], + serde_json::json!(true) + ); + let builds = harness.build_argv.borrow(); + let unsafe_builds: Vec<&Vec> = builds + .iter() + .filter(|argv| { + argv.iter() + .any(|arg| arg == "--aspects_parameters=unsafe_fixes=true") + }) + .collect(); + assert_eq!(unsafe_builds.len(), 3, "{builds:?}"); + assert!( + unsafe_builds + .iter() + .all(|argv| argv.iter().any(|arg| arg.starts_with("--aspects="))) + ); +} + +#[test] +fn fix_apply_fails_when_verification_finds_drift() { + let mut harness = Harness::new("umbrella-fix-verify-fails"); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + let staged = harness.staged_artifacts(); + let drift = harness.valid_result( + vec![Harness::diagnostic("unused", true)], + vec![harness.replacement(b"y")], + ); + let verify_files: Vec<(PathBuf, Vec)> = staged + .iter() + .map(|(path, _)| (path.clone(), drift.clone())) + .collect(); + let (code, out, err) = fix_apply_format_edit_then_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=text"], + ); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "only the apply pass writes; verification never does" + ); + assert!( + err.contains("dx: verification_failed: format failed verification after apply"), + "{err}" + ); + assert!( + !err.contains("verification skipped"), + "lint and typecheck touched nothing, so none is left to skip: {err}" + ); + assert!(out.contains("Running format analysis for //..."), "{out}"); + assert_eq!( + out.matches("Running format analysis for //...").count(), + 2, + "apply ran format, then verification re-ran it: {out}" + ); + assert_eq!( + out.matches("Running lint analysis for //...").count(), + 1, + "verification stops after the first failing phase: {out}" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 7, + "five apply launches plus generate and format verification" + ); +} + +#[test] +fn fix_apply_json_reports_verification_failure() { + let mut harness = Harness::new("umbrella-fix-verify-json"); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + let staged = harness.staged_artifacts(); + let drift = harness.valid_result( + vec![Harness::diagnostic("unused", true)], + vec![harness.replacement(b"y")], + ); + let verify_files: Vec<(PathBuf, Vec)> = staged + .iter() + .map(|(path, _)| (path.clone(), drift.clone())) + .collect(); + let (code, out, _) = fix_apply_format_edit_then_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=json"], + ); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + assert_eq!(events.first().expect("first")["command"], "check"); + assert_eq!(events.first().expect("first")["mode"], "default"); + let failure = event(&events, "error"); + assert_eq!(failure["code"], serde_json::json!("verification_failed")); + assert_eq!(failure["phase"], serde_json::json!("format")); + assert_eq!(failure["correlation"], serde_json::json!("check/format")); + assert_eq!( + events.last().expect("last")["exit_code"], + serde_json::json!(1) + ); +} + +#[test] +fn verification_failure_names_the_skipped_phases() { + let mut out = Vec::new(); + let mut err = Vec::new(); + announce_verification_failure( + &mut out, + &mut err, + OutputMode::Json, + "check", + "format", + &["lint", "typecheck"], + ) + .expect("announced"); + let err = String::from_utf8(err).expect("stderr"); + assert_eq!( + err, + "dx: verification_failed: format failed verification after apply; verification skipped for lint, typecheck\n" + ); + let events = json_events(&String::from_utf8(out).expect("stdout")); + assert_eq!(events[0]["code"], serde_json::json!("verification_failed")); + assert_eq!(events[0]["phase"], serde_json::json!("format")); +} + +#[test] +fn verification_collection_failure_is_shown_without_a_failed_phase() { + let mut verification = Verification { + failed_phase: None, + skipped: vec![], + apply_failed: vec![], + verified: vec![], + out: vec![], + err: vec![], + readable: true, + ended: None, + }; + let forwarded = forward_phase( + &mut verification.out, + &mut verification.err, + OutputMode::Json, + ("check", "check"), + "verify/lint", + (b"not an event\n", b""), + 0, + ); + assert_eq!(forwarded, Ok(false)); + verification.readable = false; + let (mut out, mut err) = (Vec::new(), Vec::new()); + deliver_verification(&mut out, &mut err, OutputMode::Json, "check", &verification) + .expect("delivered"); + let err = String::from_utf8(err).expect("utf8"); + assert!( + err.contains("dx: collection_failed: verify/lint wrote an unreadable event"), + "{err}" + ); + let out = String::from_utf8(out).expect("utf8"); + let events = json_events(&out); + let failures: Vec<_> = events.iter().filter(|e| e["event"] == "error").collect(); + assert_eq!(failures.len(), 1, "{out}"); + assert_eq!(failures[0]["code"], serde_json::json!("collection_failed")); + assert_eq!( + failures[0]["correlation"], + serde_json::json!("check/verify/lint") + ); + assert!(!out.contains("verification_failed"), "{out}"); +} + +#[test] +fn clean_verification_delivers_nothing() { + let verification = Verification { + failed_phase: None, + skipped: vec![], + apply_failed: vec![], + verified: vec![], + out: b"buffered out\n".to_vec(), + err: b"buffered err\n".to_vec(), + readable: true, + ended: None, + }; + let (mut out, mut err) = (Vec::new(), Vec::new()); + deliver_verification(&mut out, &mut err, OutputMode::Json, "check", &verification) + .expect("delivered"); + assert!(out.is_empty() && err.is_empty()); +} + +fn oscillating(harness: &Harness) -> Vec { + let mut result = quality_result::decode_validated(&harness.valid_result(vec![], vec![])) + .expect("valid result"); + result.convergence = quality_result::proto::Convergence::Oscillation as i32; + result.completed_rounds = 2; + quality_result::encode_validated(&result).expect("encode") +} + +const NOT_CONVERGED: &str = "dx: not_converged: //test:corpus did not converge: \ + OSCILLATION after 2 round(s) of lint-tool"; + +#[test] +fn check_and_fix_keep_going_after_a_diagnostic_free_unstable_phase() { + for words in [["check", "--output=text"], ["check", "--apply"]] { + let mut harness = Harness::new("umbrella-unstable"); + harness.write_source("src/a.py", "x = 1\n"); + let bytes = oscillating(&harness); + harness.results.insert("//test:corpus".to_owned(), bytes); + let (code, out, err) = harness.run(&words); + assert_eq!(code, 1, "{words:?}: {out}{err}"); + assert!(err.contains(NOT_CONVERGED), "{words:?}: {err}"); + assert!(out.contains("Running lint analysis"), "{words:?}: {out}"); + assert!(out.contains("Running generate"), "{words:?}: {out}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"x = 1\n", + "{words:?} keeps the source bytes" + ); + assert_eq!(harness.seen_env.borrow().len(), 4, "{words:?}"); + } +} + +#[test] +fn check_json_forwards_the_unstable_producer() { + let mut harness = Harness::new("umbrella-unstable-json"); + harness.write_source("src/a.py", "x = 1\n"); + let bytes = oscillating(&harness); + harness.results.insert("//test:corpus".to_owned(), bytes); + let (code, out, _) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let failure = events + .iter() + .find(|event| event["event"] == "error" && event["correlation"] != "check/generate") + .expect("error event"); + assert_eq!(failure["code"], "not_converged"); + assert_eq!( + failure["message"], + "//test:corpus did not converge: OSCILLATION after 2 round(s) of lint-tool" + ); + assert_eq!(events.last().expect("last")["exit_code"], 1); +} + +#[test] +fn fix_apply_verification_fails_on_an_unstable_recheck() { + let mut harness = Harness::new("umbrella-fix-verify-unstable"); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + let staged = harness.staged_artifacts(); + let unstable = + quality_result::decode_validated(&harness.result_full(vec![], vec![], vec![], vec![])) + .map(|mut result| { + result.convergence = quality_result::proto::Convergence::Oscillation as i32; + result.completed_rounds = 2; + quality_result::encode_validated(&result).expect("encode") + }) + .expect("valid result"); + let verify_files: Vec<(PathBuf, Vec)> = staged + .iter() + .map(|(path, _)| (path.clone(), unstable.clone())) + .collect(); + let (code, out, err) = fix_apply_format_edit_then_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=text"], + ); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "the stable apply pass wrote its edit" + ); + assert!(err.contains(NOT_CONVERGED), "{err}"); + assert!( + err.contains("dx: verification_failed: format failed verification after apply"), + "{err}" + ); + assert!( + !err.contains("verification skipped"), + "lint and typecheck touched nothing, so none is left to skip: {err}" + ); +} + +#[test] +fn fix_keeps_going_when_a_later_phase_goes_stale() { + let harness = umbrella_findings("umbrella-stale"); + let (code, out, _) = harness.run(&["check", "--apply", "--output=text"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("Running format analysis for //..."), "{out}"); + assert!(out.contains("Running lint analysis for //..."), "{out}"); + assert!(out.contains("Running typecheck analysis"), "{out}"); + assert!(out.contains("Running generate"), "{out}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "format applied before lint went stale" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 5, + "every phase launches once, then format is re-checked; stale lint is not" + ); + assert!( + harness + .seen_env + .borrow() + .iter() + .flatten() + .any(|(key, _)| key == GENERATE_ENV_INTENDED), + "generate dispatch ran" + ); +} + +/// A `check --apply` whose format edit goes stale before the apply pass. +fn refused_format(name: &str) -> Harness { + let mut harness = Harness::new(name); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + harness.write_source("src/a.py", "z = 2\n"); + harness +} + +#[test] +fn fix_apply_fails_when_format_refuses_a_stale_source() { + let harness = refused_format("umbrella-fix-refused"); + let (code, out, err) = harness.run(&["check", "--apply", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"z = 2\n" + ); + assert!( + err.contains("Not applied: src/a.py (stale_source)"), + "{err}" + ); + assert!(out.contains("Running lint analysis for //..."), "{out}"); + assert!(out.contains("Running typecheck analysis"), "{out}"); + assert!(out.contains("Running generate"), "{out}"); + assert_eq!( + harness.seen_env.borrow().len(), + 5, + "every phase launches once, then generate is re-checked; the refused format is not" + ); + + let harness = refused_format("umbrella-fix-refused-json"); + let (code, out, err) = harness.run(&["check", "--apply", "--output=json"]); + assert_eq!(code, 1, "{out}{err}"); + let events = json_events(&out); + let format_finished = events_of_kind(&events, "phase_finished") + .into_iter() + .find(|event| event["phase"] == "format") + .expect("format finished"); + assert_eq!(format_finished["exit_code"], 1, "{out}"); + assert_eq!( + format_finished["changes"]["refused"], + serde_json::json!(1), + "{out}" + ); + assert!( + events.iter().all(|event| !event["correlation"] + .as_str() + .is_some_and(|correlation| correlation.starts_with("verify/"))), + "{out}" + ); + assert_eq!( + events.last().expect("last")["exit_code"], + serde_json::json!(1) + ); + assert_eq!(harness.seen_env.borrow().len(), 5); +} + +#[test] +fn fix_runs_every_phase_after_a_tool_failure() { + let harness = umbrella_clean("umbrella-fix-tool-failure"); + let runner = FailFrom { + inner: harness.runner(), + from: 1, + to: Some(2), + code: 1, + launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!(runner.launches.get(), 4, "every phase launches"); + let events = json_events(&out); + let started: Vec<&str> = events_of_kind(&events, "phase_started") + .iter() + .map(|event| event["phase"].as_str().expect("phase")) + .collect(); + assert_eq!(started, vec!["generate", "format", "lint", "typecheck"]); + let failures: Vec<&str> = events_of_kind(&events, "error") + .iter() + .map(|event| event["correlation"].as_str().expect("correlation")) + .collect(); + assert_eq!(failures, vec!["check/format"], "{out}"); + assert!( + events_of_kind(&events, "notice") + .iter() + .all(|notice| notice["code"] != "umbrella_phase_skipped"), + "{out}" + ); + let finished = *events_of_kind(&events, "command_finished") + .last() + .expect("umbrella finished"); + assert_eq!(finished["exit_code"], serde_json::json!(1)); + assert_eq!(finished["results_complete"], serde_json::json!(false)); +} + +/// Runs `fix --apply --output=` where lint (launch 2) exits 7 and +/// the verification launches see `verify_result`; only format edits. +fn fix_apply_lint_fails( + harness: &Harness, + verify_result: Vec, + output: &str, +) -> (i32, String, String) { + let mut staged = staged_runner( + harness, + harness + .staged_artifacts() + .iter() + .map(|(path, _)| (path.clone(), verify_result.clone())) + .collect(), + Some(intended_witness("check", true, "", "")), + 5, + ); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + staged.clean_files = staged + .apply_files + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + staged.clean_from = 1; + let runner = FailFrom { + inner: staged, + from: 2, + to: Some(3), + code: 7, + launches: Cell::new(0), + }; + let output_flag = format!("--output={output}"); + let inv = umbrella_invocation(harness, &["check", "--apply", output_flag.as_str()]); + harness.execute_with(&inv, &runner) +} + +fn partial_failure_harness(name: &str) -> Harness { + let mut harness = Harness::new(name); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + harness +} + +#[test] +fn fix_apply_partial_failure_rechecks_passing_phases() { + let harness = partial_failure_harness("umbrella-fix-partial-recheck"); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + let (code, out, err) = fix_apply_lint_fails(&harness, clean, "json"); + let events = json_events(&out); + let lint_code = events + .iter() + .find(|event| event["event"] == "phase_finished" && event["phase"] == "lint") + .expect("lint finished")["exit_code"] + .as_i64() + .expect("lint code"); + assert_ne!(lint_code, 0, "{out}"); + assert_eq!(i64::from(code), lint_code, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "format applied before lint failed" + ); + let verify: Vec<&str> = events + .iter() + .filter_map(|event| event["correlation"].as_str()) + .filter(|correlation| correlation.starts_with("check/verify/")) + .collect(); + assert!(verify.contains(&"check/verify/format"), "{out}{err}"); + assert!(!verify.contains(&"check/verify/lint"), "{out}"); + assert!( + err.contains("verification passed after apply for generate, format") + && err.contains("failed during apply: lint"), + "{err}" + ); + assert_eq!( + events_of_kind(&events, "command_finished").len(), + 1, + "{out}" + ); + assert_eq!( + events.last().expect("last")["exit_code"], + serde_json::json!(lint_code) + ); +} + +#[test] +fn fix_apply_partial_failure_reports_a_failing_recheck() { + let harness = partial_failure_harness("umbrella-fix-partial-recheck-fails"); + let unstable = + quality_result::decode_validated(&harness.result_full(vec![], vec![], vec![], vec![])) + .map(|mut result| { + result.convergence = quality_result::proto::Convergence::Oscillation as i32; + result.completed_rounds = 2; + quality_result::encode_validated(&result).expect("encode") + }) + .expect("valid result"); + let (code, out, err) = fix_apply_lint_fails(&harness, unstable, "text"); + assert_eq!(code, 7, "the failed phase's code stays: {out}{err}"); + assert!( + err.contains("dx: verification_failed: format failed verification after apply"), + "{err}" + ); +} + +#[test] +fn fix_apply_does_not_recheck_the_failed_phase() { + let mut harness = Harness::new("umbrella-fix-failed-no-verify"); + harness.write_source("src/a.py", "x = 1\n"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement(b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + let staged = staged_runner( + &harness, + harness.staged_artifacts(), + Some(intended_witness("check", true, "", "")), + 5, + ); + let runner = FailFrom { + inner: staged, + from: 3, + to: Some(4), + code: 1, + launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "format applied before typecheck failed" + ); + assert_eq!( + runner.launches.get(), + 7, + "five phase launches plus the generate and format rechecks, never the failed typecheck" + ); + let events = json_events(&out); + assert!( + events.iter().all(|event| !event["correlation"] + .as_str() + .is_some_and(|correlation| correlation == "verify/typecheck")), + "{out}" + ); + assert_eq!( + events.last().expect("last")["exit_code"], + serde_json::json!(1) + ); +} + +#[test] +fn check_runs_every_phase_after_a_tool_failure() { + let harness = umbrella_clean("umbrella-check-tool-failure"); + let runner = FailFrom { + inner: harness.runner(), + from: 2, + to: Some(3), + code: 1, + launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", "--output=text"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!( + out.contains("Running typecheck analysis for //..."), + "{out}" + ); + assert!(out.contains("Running generate for //..."), "{out}"); + assert!( + err.contains("dx: analysis_failed: lint analysis failed"), + "{err}" + ); + assert!( + err.contains("dx: phases: generate clean, format clean, lint failed, typecheck clean"), + "{err}" + ); + assert_eq!(runner.launches.get(), 4, "every phase launches"); +} + +#[test] +fn check_runs_every_phase_after_a_lint_compile_error() { + let harness = umbrella_clean("umbrella-check-compile"); + let runner = compile_fail_at_lint(&harness); + let inv = umbrella_invocation(&harness, &["check", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!(runner.launches.get(), 4, "every phase launches"); + let events = json_events(&out); + assert!( + events_of_kind(&events, "notice") + .iter() + .all(|notice| notice["code"] != "umbrella_phase_skipped"), + "{out}" + ); + let errors = events_of_kind(&events, "error"); + assert!( + errors.iter().any(|event| event["code"] == "bazel_failed" + && event["cause"] == "bazel" + && event["correlation"] == "check/lint"), + "{out}" + ); + assert!(!out.contains("analysis_failed"), "{out}"); + let failures = events_of_kind(&events, "build_failure"); + assert_eq!(failures.len(), 1, "{out}"); + assert_eq!(failures[0]["correlation"], "check/lint"); + assert_eq!( + failures[0]["diagnostics"]["items"][0]["path"], + "app/src/main.rs" + ); + assert_eq!(events.last().expect("last")["exit_code"], 1, "{out}"); +} + +#[test] +fn fix_stops_after_a_lint_compile_error() { + let harness = umbrella_clean("umbrella-fix-compile"); + let runner = compile_fail_at_lint(&harness); + let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!(runner.launches.get(), 3, "typecheck never launches"); + let events = json_events(&out); + let skipped: Vec<&str> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == "umbrella_phase_skipped") + .filter_map(|notice| notice["message"].as_str()) + .collect(); + assert_eq!( + skipped, + vec!["typecheck skipped after lint failed to compile"] + ); + let errors = events_of_kind(&events, "error"); + assert!( + errors + .iter() + .any(|event| event["code"] == "bazel_failed" && event["correlation"] == "check/lint"), + "{out}" + ); +} + +#[test] +fn a_selection_stop_without_an_error_event_gains_one_before_the_closing_event() { + let started = br#"{"event":"phase_started","phase":"format"}"#; + let finished = br#"{"event":"phase_finished","phase":"format","exit_code":2}"#; + let mut out = [&started[..], b"\n", &finished[..], b"\n"].concat(); + let mut err = b"ERROR: no such target\nINFO: done\n".to_vec(); + explain_selection_stop(OutputMode::Json, "format", 2, (&mut out, &mut err)); + let lines: Vec = String::from_utf8(out) + .expect("utf8") + .lines() + .map(|line| serde_json::from_str(line).expect("event")) + .collect(); + assert_eq!(lines.len(), 3, "{lines:?}"); + assert_eq!(lines[1]["event"], "error"); + assert_eq!(lines[1]["code"], "invalid_scope"); + assert_eq!(lines[2]["event"], "phase_finished"); + let message = lines[1]["message"].as_str().expect("message"); + assert!(message.contains("INFO: done"), "{message}"); + assert!( + message.contains("pass a Bazel label or package"), + "{message}" + ); +} + +#[test] +fn a_selection_stop_that_already_has_an_error_event_is_left_alone() { + let error = br#"{"event":"error","code":"invalid_arguments","message":"x"}"#; + let mut out = [&error[..], b"\n"].concat(); + let before = out.clone(); + let mut err = Vec::new(); + explain_selection_stop(OutputMode::Json, "format", 2, (&mut out, &mut err)); + assert_eq!(out, before); +} + +#[test] +fn a_silent_text_selection_stop_writes_a_stderr_line() { + let mut out = Vec::new(); + let mut err = Vec::new(); + explain_selection_stop( + OutputMode::Text { quiet: false }, + "format", + 2, + (&mut out, &mut err), + ); + let text = String::from_utf8(err).expect("utf8"); + assert!( + text.starts_with("dx: invalid_scope: format rejected"), + "{text}" + ); +} + +#[test] +fn umbrella_stdout_report_is_rejected_pre_exec() { + let harness = umbrella_clean("umbrella-stdout-report"); + let (code, _, err) = harness.run(&["check", "--report=sarif=-", "--output=text"]); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("share one stdout document"), "{err}"); + assert!(harness.seen_env.borrow().is_empty(), "no phase launched"); +} + +#[test] +fn umbrella_unknown_report_is_rejected_pre_exec() { + let harness = umbrella_clean("umbrella-unknown-report"); + let (code, _, err) = harness.run(&["check", "--report=junit=out.xml"]); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("unsupported report format"), "{err}"); + assert!(harness.seen_env.borrow().is_empty(), "no phase launched"); +} + +#[test] +fn umbrella_sarif_merges_executed_phases_in_order() { + let harness = umbrella_clean("umbrella-sarif"); + let (code, out, err) = harness.run(&["check", "--output=json", "--report=sarif=out.sarif"]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let report = *events_of_kind(&events, "report") + .last() + .expect("umbrella report"); + assert_eq!(report["format"], serde_json::json!("sarif")); + assert_eq!(report["path"], serde_json::json!("out.sarif")); + assert_eq!( + report["results_complete"], + serde_json::json!(true), + "every phase ran and every capture parsed" + ); + assert_eq!( + event(&events, "command_finished")["results_complete"], + serde_json::json!(true) + ); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(harness.workspace.join("out.sarif")).expect("sarif")) + .expect("SARIF JSON"); + assert_eq!( + document["runs"].as_array().expect("runs").len(), + 2, + "lint and typecheck each contribute one run" + ); + assert_eq!( + document["runs"][0], document["runs"][1], + "both phases replay the shared clean fixture" + ); + + let (code, out, _) = harness.run(&["check", "--output=text", "--report=sarif=out.sarif"]); + assert_eq!(code, 0, "{out}"); + assert_eq!( + out.lines().last().expect("last line"), + "Wrote sarif report to out.sarif." + ); +} + +#[test] +fn fix_sarif_covers_only_executed_phases() { + let mut harness = umbrella_findings("umbrella-sarif-stop"); + harness.free_disk_bytes = Some(1 << 20); + let run = |output: &str| { + let runner = FailFrom { + inner: harness.runner(), + from: 2, + to: None, + code: 36, + launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", output, "--report=sarif=out.sarif"]); + harness.execute_with(&inv, &runner) + }; + let (code, out, err) = run("--output=json"); + assert_eq!(code, 36, "{out}{err}"); + let events = json_events(&out); + assert_eq!( + events_of_kind(&events, "report") + .last() + .expect("umbrella report")["results_complete"], + serde_json::json!(false), + "a run stopped at lint leaves the report incomplete" + ); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(harness.workspace.join("out.sarif")).expect("sarif")) + .expect("SARIF JSON"); + assert_eq!( + document["runs"].as_array().expect("runs").len(), + 0, + "no SARIF-capable phase finished, no runs" + ); + + let (code, _, err) = run("--diff"); + assert_eq!(code, 36); + assert!( + err.ends_with( + "Wrote sarif report to out.sarif (incomplete: generate failed, \ + lint failed, typecheck skipped).\n\ + dx: phases: generate failed, format findings, lint failed, typecheck skipped\n" + ), + "{err}" + ); +} + +#[test] +fn umbrella_sarif_skips_phase_that_could_not_render() { + let harness = umbrella_findings("umbrella-sarif-unrendered"); + let (code, out, err) = harness.run(&[ + "check", + "--apply", + "--output=text", + "--report=sarif=out.sarif", + ]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("failed to render SARIF report"), "{err}"); + assert!( + out.contains( + "Wrote sarif report to out.sarif \ + (incomplete: generate failed, lint failed, typecheck failed)." + ), + "{out}" + ); + let document: serde_json::Value = + serde_json::from_slice(&std::fs::read(harness.workspace.join("out.sarif")).expect("sarif")) + .expect("SARIF JSON"); + assert!( + document["runs"].as_array().expect("runs").is_empty(), + "the phase that never rendered contributes no runs" + ); +} + +#[test] +fn umbrella_report_write_failure_fails() { + let harness = umbrella_clean("umbrella-report-fail"); + let (code, _, err) = harness.run(&[ + "check", + "--output=text", + "--report=sarif=missing-dir/out.sarif", + ]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("report_failed"), "{err}"); + + let (code, out, _) = harness.run(&[ + "check", + "--output=json", + "--report=sarif=missing-dir/out.sarif", + ]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("\"code\":\"report_failed\""), "{out}"); +} + +#[test] +fn umbrella_report_into_a_directory_ends_with_one_finished() { + let harness = umbrella_clean("umbrella-report-dir"); + std::fs::create_dir(harness.workspace.join("out.sarif")).expect("directory"); + let (code, out, _) = harness.run(&["check", "--output=json", "--report=sarif=out.sarif"]); + assert_eq!(code, 1, "{out}"); + let events = json_events(&out); + let finished = events + .iter() + .filter(|event| event["event"] == "command_finished") + .count(); + assert_eq!(finished, 1, "{out}"); + assert_eq!( + events.last().expect("last")["event"], + "command_finished", + "{out}" + ); + assert!( + events.iter().any(|event| event["code"] == "report_failed"), + "{out}" + ); +} + +#[test] +fn umbrella_failed_phase_and_failed_report_still_fails() { + let harness = umbrella_findings("umbrella-report-fail-stop"); + let (code, _, err) = harness.run(&[ + "check", + "--output=text", + "--report=sarif=missing-dir/out.sarif", + ]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("report_failed"), "{err}"); +} + +#[test] +fn fix_json_reports_umbrella_lifecycle() { + let harness = umbrella_clean("umbrella-fix-json"); + let (code, out, err) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + assert_eq!(events.first().expect("first")["command"], "check"); + assert_eq!( + events.first().expect("first")["mode"], + "check", + "bare fix checks without --apply" + ); + assert_eq!(events.last().expect("last")["exit_code"], 0); +} + +#[test] +fn umbrella_diff_renders_every_phase_patch() { + let harness = umbrella_findings("umbrella-diff"); + let (code, out, _) = harness.run(&["check", "--diff"]); + assert_eq!(code, 1, "{out}"); + assert!(out.contains("src/a.py"), "{out}"); + assert_eq!(harness.seen_env.borrow().len(), 4, "every phase launches"); +} + +#[test] +fn check_json_correlates_one_phase_start_per_phase() { + let harness = umbrella_clean("umbrella-phase-events"); + let (code, out, err) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + assert!(events_of_kind(&events, "operation").is_empty()); + let started = events_of_kind(&events, "phase_started"); + let described: Vec<(&str, &str, &str)> = started + .iter() + .map(|event| { + ( + event["command"].as_str().expect("command"), + event["phase"].as_str().expect("phase"), + event["mode"].as_str().expect("mode"), + ) + }) + .collect(); + assert_eq!( + described, + vec![ + ("check", "generate", "check"), + ("check", "format", "check"), + ("check", "lint", "check"), + ("check", "typecheck", "check") + ] + ); + for (index, phase) in ["generate", "format", "lint", "typecheck"] + .iter() + .enumerate() + { + assert_eq!( + started[index]["correlation"], + serde_json::json!(format!("check/{phase}")), + "{phase}" + ); + } + assert!( + events_of_kind(&events, "notice").is_empty(), + "nothing skipped, nothing to notice" + ); +} + +#[test] +fn check_skips_the_later_phases_after_bazel_runs_out_of_memory() { + let harness = umbrella_clean("umbrella-out-of-memory"); + let inv = umbrella_invocation(&harness, &["check", "--output=json"]); + let runner = FailFrom { + inner: harness.runner(), + from: 2, + to: None, + code: 33, + launches: Cell::new(0), + }; + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 33, "{out}{err}"); + let events = json_events(&out); + let errors = events_of_kind(&events, "error"); + assert_eq!(errors[0]["code"], serde_json::json!("server_out_of_memory")); + assert_eq!(errors[0]["cause"], serde_json::json!("host")); + let skipped = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == serde_json::json!("umbrella_phase_skipped")) + .count(); + assert!(skipped > 0, "{out}"); +} + +#[test] +fn check_reports_a_full_disk_once_and_skips_the_later_phases() { + let mut harness = umbrella_clean("umbrella-disk-full"); + harness.free_disk_bytes = Some(1 << 20); + let inv = umbrella_invocation(&harness, &["check", "--output=json"]); + let runner = FailFrom { + inner: harness.runner(), + from: 2, + to: None, + code: 36, + launches: Cell::new(0), + }; + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 36, "{out}{err}"); + assert!(err.contains("dx: disk_full: "), "{err}"); + assert!(!err.contains("analysis_failed"), "{err}"); + let events = json_events(&out); + let errors = events_of_kind(&events, "error"); + assert_eq!(errors.len(), 1, "{out}"); + assert_eq!(errors[0]["code"], serde_json::json!("disk_full")); + assert_eq!(errors[0]["cause"], serde_json::json!("host")); + assert_eq!(errors[0]["correlation"], serde_json::json!("check/lint")); + let skipped: Vec<&str> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == serde_json::json!("umbrella_phase_skipped")) + .filter_map(|notice| notice["message"].as_str()) + .collect(); + assert_eq!( + skipped, + vec!["typecheck skipped after lint failed to run its tools"] + ); +} + +#[test] +fn check_json_names_every_skipped_phase() { + let mut harness = umbrella_clean("umbrella-skip-events"); + harness.free_disk_bytes = Some(1 << 20); + let runner = FailFrom { + inner: harness.runner(), + from: 2, + to: None, + code: 36, + launches: Cell::new(0), + }; + let inv = umbrella_invocation( + &harness, + &["check", "--output=json", "--report=sarif=out.sarif"], + ); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 36, "{out}{err}"); + let events = json_events(&out); + let skipped: Vec<(&str, &str)> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] != "disk_low") + .map(|notice| { + ( + notice["code"].as_str().expect("code"), + notice["message"].as_str().expect("message"), + ) + }) + .collect(); + assert_eq!( + skipped, + vec![( + CODE_UMBRELLA_PHASE_SKIPPED, + "typecheck skipped after lint failed to run its tools" + )] + ); + for notice in events_of_kind(&events, "notice") + .into_iter() + .filter(|notice| notice["code"] == "umbrella_phase_skipped") + { + assert_eq!(notice["related_command"], serde_json::json!("check")); + assert!(notice["correlation"].is_string(), "{notice}"); + } + assert_eq!( + events_of_kind(&events, "report") + .last() + .expect("umbrella report")["results_complete"], + serde_json::json!(false) + ); + let finished = *events_of_kind(&events, "command_finished") + .last() + .expect("umbrella finished"); + assert_eq!(finished["exit_code"], serde_json::json!(36)); + assert_eq!(finished["results_complete"], serde_json::json!(false)); + assert_eq!( + harness.seen_env.borrow().len(), + 3, + "unsafe work never starts" + ); +} + +#[test] +fn check_missing_capture_fails_the_run_and_keeps_valid_partial_runs() { + let (harness, code, out, err) = umbrella_with_broken_capture( + umbrella_clean("umbrella-missing-capture"), + Rewrite::Remove, + &["check", "--output=text", "--report=sarif=out.sarif"], + ); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + err.replace("no findings in 0 target(s)\n", ""), + "dx: collection_failed: lint wrote no sarif capture\n", + "{err}" + ); + assert_eq!( + out.lines().last().expect("last line"), + "Wrote sarif report to out.sarif (incomplete: lint wrote no sarif capture)." + ); + for phase in ["format", "lint", "typecheck", "generate"] { + let marker = match phase { + "generate" => "Running generate for //...", + _ => &format!("Running {phase} analysis for //..."), + }; + assert!(out.contains(marker), "{phase} ran: {out}"); + } + let runs = merged_runs(&harness); + assert_eq!( + runs.len(), + 1, + "the typecheck capture still lands in the merged document" + ); + assert!( + !capture_path(&harness, "lint", 2).exists(), + "the capture is deleted even when it was never written" + ); +} + +#[test] +fn check_missing_capture_reports_an_incomplete_json_document() { + let (harness, code, out, err) = umbrella_with_broken_capture( + umbrella_clean("umbrella-missing-capture-json"), + Rewrite::Remove, + &["check", "--output=json", "--report=sarif=out.sarif"], + ); + assert_eq!(code, 1, "{out}{err}"); + let events = json_events(&out); + let failure = event(&events, "error"); + assert_eq!(failure["code"], serde_json::json!(CODE_COLLECTION_FAILED)); + assert_eq!( + failure["message"], + serde_json::json!("lint wrote no sarif capture") + ); + assert_eq!(failure["phase"], serde_json::json!("lint")); + assert_eq!(failure["correlation"], serde_json::json!("check/lint")); + assert_eq!( + events_of_kind(&events, "report") + .last() + .expect("umbrella report")["results_complete"], + serde_json::json!(false) + ); + let finished = *events_of_kind(&events, "command_finished") + .last() + .expect("umbrella finished"); + assert_eq!(finished["exit_code"], serde_json::json!(1)); + assert_eq!(finished["results_complete"], serde_json::json!(false)); + assert_eq!(merged_runs(&harness).len(), 1, "{out}"); +} + +#[test] +fn check_unreadable_capture_fails_the_run() { + let (harness, code, out, err) = umbrella_with_broken_capture( + umbrella_clean("umbrella-unreadable-capture"), + Rewrite::Block, + &["check", "--output=text", "--report=sarif=out.sarif"], + ); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + err.replace("no findings in 0 target(s)\n", ""), + "dx: collection_failed: lint sarif capture is unreadable\n", + "{err}" + ); + assert!( + out.contains("(incomplete: lint sarif capture is unreadable)"), + "{out}" + ); + assert_eq!(merged_runs(&harness).len(), 1, "{out}"); +} + +#[test] +fn fix_apply_mode_shares_the_same_capture_collector() { + let mut harness = umbrella_clean("umbrella-fix-missing-capture"); + harness.intended = Some(intended_witness("default", true, "", "")); + let staged = harness.staged_artifacts(); + let runner = staged_runner( + &harness, + staged, + Some(intended_witness("check", true, "", "")), + 4, + ); + let capture = capture_path(&harness, "lint", 2); + let breaking = RewriteCapture { + inner: runner, + capture, + rewrite: Rewrite::Remove, + done: Cell::new(false), + }; + let inv = umbrella_invocation( + &harness, + &[ + "check", + "--apply", + "--output=text", + "--report=sarif=out.sarif", + ], + ); + let (code, out, err) = harness.execute_with(&inv, &breaking); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + err.replace("no findings in 0 target(s)\n", ""), + "dx: collection_failed: lint wrote no sarif capture\n", + "{err}" + ); + assert!( + out.contains("Running generate for //..."), + "apply mode still runs every phase: {out}" + ); + assert_eq!( + out.lines().last().expect("last line"), + "Wrote sarif report to out.sarif (incomplete: lint wrote no sarif capture)." + ); + assert_eq!(merged_runs(&harness).len(), 1, "{out}"); +} + +#[test] +fn check_malformed_capture_fails_the_run() { + for (name, payload) in [ + ("not json at all", &b"{not json"[..]), + ("an empty document", &b"{}"[..]), + ("a document without runs", &br#"{"version":"2.1.0"}"#[..]), + ( + "a document whose runs are not an array", + &br#"{"version":"2.1.0","runs":{}}"#[..], + ), + ( + "a run without a tool", + &br#"{"version":"2.1.0","runs":[{"nope":1}]}"#[..], + ), + ( + "a tool without a driver", + &br#"{"version":"2.1.0","runs":[{"tool":{}}]}"#[..], + ), + ( + "a driver without a name", + &br#"{"version":"2.1.0","runs":[{"tool":{"driver":{}}}]}"#[..], + ), + ("a bare JSON array", &b"[]"[..]), + ] { + let (harness, code, out, err) = umbrella_with_broken_capture( + umbrella_clean(&format!("umbrella-malformed-{name}")), + Rewrite::Bytes(payload), + &["check", "--output=text", "--report=sarif=out.sarif"], + ); + assert_eq!(code, 1, "{name}: {out}{err}"); + assert_eq!( + err.replace("no findings in 0 target(s)\n", ""), + "dx: collection_failed: lint sarif capture is malformed\n", + "{name}: {err}" + ); + assert_eq!(merged_runs(&harness).len(), 1, "{name}: {out}"); + } +} + +#[test] +fn check_merged_document_is_deterministic() { + let (harness, code, _, err) = umbrella_with_broken_capture( + umbrella_clean("umbrella-deterministic"), + Rewrite::Bytes(br#"{"version":"2.1.0","runs":[{"nope":1}]}"#), + &["check", "--output=text", "--report=sarif=out.sarif"], + ); + assert_eq!(code, 1, "{err}"); + let first = std::fs::read(harness.workspace.join("out.sarif")).expect("first"); + let (code, _, err) = harness.run(&["check", "--output=text", "--report=sarif=out.sarif"]); + assert_eq!(code, 0, "{err}"); + let complete = std::fs::read(harness.workspace.join("out.sarif")).expect("second"); + assert_eq!(merged_runs(&harness).len(), 2); + assert_ne!(first, complete, "the broken run never reaches the document"); +} + +#[test] +fn umbrella_broken_pipe_stdout_fails_with_141() { + struct BrokenPipe; + impl Write for BrokenPipe { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) + } + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")) + } + } + + let harness = umbrella_clean("umbrella-broken-pipe"); + let inv = umbrella_invocation( + &harness, + &["check", "--output=text", "--report=sarif=out.sarif"], + ); + let runner = harness.runner(); + let mut out = BrokenPipe; + let mut err = Vec::new(); + assert_eq!(harness.run_streams(&inv, &runner, &mut out, &mut err), 141); + assert_eq!( + harness.seen_env.borrow().len(), + 1, + "a broken phase stream stops the run after the phase that filled it" + ); + assert!( + !harness.workspace.join("out.sarif").exists(), + "no report is announced or written after stdout breaks" + ); +} + +#[test] +fn umbrella_stdout_write_failure_is_operational() { + struct FailAfterOne { + seen: usize, + } + impl Write for FailAfterOne { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.seen += 1; + if self.seen == 1 { + return Ok(buf.len()); + } + Err(io::Error::other("boom")) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let harness = umbrella_clean("umbrella-stdout-failure"); + let inv = umbrella_invocation(&harness, &["check", "--output=json"]); + let runner = harness.runner(); + let mut out = FailAfterOne { seen: 0 }; + let mut err = Vec::new(); + assert_eq!( + harness.run_streams(&inv, &runner, &mut out, &mut err), + 1, + "the first event lands, the phase events do not" + ); + assert_eq!( + harness.seen_env.borrow().len(), + 1, + "no later phase launched after stdout failed" + ); + assert_eq!(out.seen, 2, "nothing is written after the failed write"); + assert!( + err.is_empty(), + "a stdout failure is not a stderr diagnostic" + ); +} + +#[test] +fn collector_classifies_every_capture_state() { + let dir = temp_dir("umbrella-collector"); + let good = dir.path().join("good.sarif"); + let bad = dir.path().join("bad.sarif"); + let missing = dir.path().join("missing.sarif"); + std::fs::write( + &good, + dx_plan::reports::render_sarif( + &["lint-tool".to_owned()], + &[], + &std::collections::BTreeMap::new(), + true, + ) + .expect("render"), + ) + .expect("good capture"); + std::fs::write(&bad, b"{}").expect("bad capture"); + + let mut collector = PhaseCollector::default(); + for phase in UMBRELLA_PHASES { + collector.plan(phase, phase.name()); + } + collector.record_exit(0, 1, PhaseKind::Tool); + collector.require_capture(1, good.clone()); + collector.record_exit(1, 0, PhaseKind::Clean); + collector.require_capture(2, bad.clone()); + collector.record_exit(2, 0, PhaseKind::Clean); + collector.require_capture(3, missing.clone()); + collector.record_exit(3, 0, PhaseKind::Clean); + collector.skip_remaining(String::new()); + collector.collect(); + + assert_eq!( + collector.collection_failures(), + vec![ + ("lint", "sarif capture is malformed"), + ("typecheck", "wrote no sarif capture"), + ] + ); + assert_eq!( + collector.reasons(), + vec![ + "generate failed".to_owned(), + "lint sarif capture is malformed".to_owned(), + "typecheck wrote no sarif capture".to_owned(), + ] + ); + assert!(!collector.complete()); + let document: serde_json::Value = serde_json::from_str(&collector.document()).expect("JSON"); + assert_eq!(document["runs"].as_array().expect("runs").len(), 1); + + for path in [&good, &bad, &missing] { + collector.cleanup(); + assert!(!path.exists(), "{path:?} deleted"); + } +} + +#[test] +fn collector_merges_a_complete_run_deterministically() { + let dir = temp_dir("umbrella-collector-complete"); + let captures: Vec = ["lint", "typecheck"] + .iter() + .map(|name| { + let path = dir.path().join(format!("{name}.sarif")); + std::fs::write( + &path, + dx_plan::reports::render_sarif( + &[format!("{name}-tool")], + &[], + &std::collections::BTreeMap::new(), + true, + ) + .expect("render"), + ) + .expect("capture"); + path + }) + .collect(); + + let mut collector = PhaseCollector::default(); + for (index, phase) in UMBRELLA_PHASES.iter().enumerate() { + collector.plan(*phase, phase.name()); + if let Some(path) = index.checked_sub(2).and_then(|at| captures.get(at)) { + collector.require_capture(index, path.clone()); + } + collector.record_exit(index, 0, PhaseKind::Clean); + } + collector.collect(); + assert!(collector.complete(), "{:?}", collector.reasons()); + assert!(collector.collection_failures().is_empty()); + assert!(collector.skipped().is_empty()); + assert_eq!(collector.reasons(), Vec::::new()); + let document = collector.document(); + assert_eq!(document, collector.document(), "same input, same bytes"); + let parsed: serde_json::Value = serde_json::from_str(&document).expect("JSON"); + let runs = parsed["runs"].as_array().expect("runs"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0]["tool"]["driver"]["name"], "lint-tool"); + assert_eq!(runs[1]["tool"]["driver"]["name"], "typecheck-tool"); +} + +#[test] +fn test_phase_kind_maps_each_bazel_exit() { + let cases = [ + (0, true, false, PhaseKind::Clean), + (3, true, false, PhaseKind::Findings), + (3, false, false, PhaseKind::Incomplete), + (1, false, false, PhaseKind::Incomplete), + (1, true, false, PhaseKind::Incomplete), + (2, false, false, PhaseKind::Selection), + (4, true, false, PhaseKind::Selection), + (8, false, false, PhaseKind::Cancelled), + (36, false, false, PhaseKind::Tool), + (36, false, true, PhaseKind::Host), + (33, false, false, PhaseKind::Host), + (141, true, false, PhaseKind::Tool), + (1, false, true, PhaseKind::Host), + (1, true, true, PhaseKind::Host), + (0, true, true, PhaseKind::Clean), + ]; + for (code, complete, host, kind) in cases { + assert_eq!( + test_phase_kind(code, complete, host), + kind, + "{code} {complete} {host}" + ); + } +} + +#[test] +fn test_phase_results_complete_follows_kind() { + let collect = |code: i32, kind: PhaseKind| { + let mut collector = PhaseCollector::default(); + for phase in UMBRELLA_PHASES { + collector.plan(phase, phase.name()); + } + collector.plan(Command::Test, "test"); + for index in 0..UMBRELLA_PHASES.len() { + collector.record_exit(index, 0, PhaseKind::Clean); + } + collector.record_exit(UMBRELLA_PHASES.len(), code, kind); + collector.skip_remaining(String::new()); + collector.collect(); + collector + }; + let findings = collect(3, PhaseKind::Findings); + assert!(findings.complete(), "{:?}", findings.reasons()); + assert!(findings.reasons().is_empty()); + assert_eq!(findings.with_findings(), vec!["test"]); + for code in [1, 3] { + let incomplete = collect(code, PhaseKind::Incomplete); + assert!(!incomplete.complete()); + assert_eq!(incomplete.reasons(), vec!["test failed".to_owned()]); + assert!(incomplete.with_findings().is_empty()); + } +} + +/// Runs the umbrella test phase against a harness and returns its outcome. +fn run_test_phase(harness: &Harness) -> (i32, PhaseKind) { + let inv = umbrella_invocation(harness, &["test", "--output=text"]); + let runner = harness.runner(); + let mut out = Vec::new(); + let mut err = Vec::new(); + let run = execute_test_phase(&inv, harness.env(&runner, &mut out, &mut err)); + assert_eq!(run.effects, PhaseEffects::default()); + (run.code, run.kind) +} + +#[test] +fn test_phase_reads_dx_test_completeness() { + const FAILING_XML: &str = r#""#; + let failed_line = |uri: String| { + test_result_full_line( + "//a:t", + 1, + 1, + 1, + "FAILED", + None, + None, + None, + &[(String::from("test.xml"), uri)], + ) + }; + let harness = Harness::new("umbrella-test-phase-findings"); + let uri = write_bep_artifact(&harness, "test.xml", FAILING_XML.as_bytes()); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![failed_line(uri)]), + ..harness + }; + assert_eq!(run_test_phase(&harness), (3, PhaseKind::Findings)); + + let harness = Harness::new("umbrella-test-phase-missing"); + let missing = format!("file://{}", harness.temp.join("absent.xml").display()); + let harness = Harness { + bazel_code: 3, + raw_bep: Some(vec![failed_line(missing)]), + ..harness + }; + assert_eq!(run_test_phase(&harness), (3, PhaseKind::Incomplete)); + + let harness = Harness { + bazel_code: 1, + ..Harness::new("umbrella-test-phase-build-failed") + }; + assert_eq!(run_test_phase(&harness), (1, PhaseKind::Incomplete)); +} + +#[test] +fn test_phase_reports_launch_failure_as_host() { + let harness = Harness { + io_error: true, + ..Harness::new("umbrella-test-phase-launch") + }; + assert_eq!(run_test_phase(&harness), (1, PhaseKind::Host)); + + let harness = Harness { + signalled: true, + ..Harness::new("umbrella-test-phase-signalled") + }; + assert_eq!(run_test_phase(&harness), (1, PhaseKind::Host)); + + let harness = Harness { + bazel_code: 1, + ..Harness::new("umbrella-test-phase-launch-build-failed") + }; + assert_eq!(run_test_phase(&harness), (1, PhaseKind::Incomplete)); +} + +#[test] +fn test_phase_reports_full_disk_as_host() { + let harness = Harness { + bazel_code: 36, + free_disk_bytes: Some(1 << 20), + ..Harness::new("umbrella-test-phase-disk-full") + }; + assert_eq!(run_test_phase(&harness), (36, PhaseKind::Host)); +} + +/// Runs `check` where format passes and lint reports findings. +fn check_with_failing_lint(name: &str, words: &[&str]) -> (Harness, i32, String, String) { + let harness = umbrella_clean(name); + let (artifact, _) = harness + .staged_artifacts() + .into_iter() + .next() + .expect("artifact"); + let findings = harness.valid_result( + vec![Harness::diagnostic("unused", true)], + vec![harness.replacement(b"y")], + ); + let staged = staged_runner( + &harness, + vec![(artifact, findings)], + harness.intended.clone(), + 1, + ); + let inv = umbrella_invocation(&harness, words); + let (code, out, err) = harness.execute_with(&inv, &staged); + (harness, code, out, err) +} + +#[test] +fn check_affected_hints_name_dx_check_apply_with_the_affected_flag() { + let harness = umbrella_findings("umbrella-affected-hint"); + harness.write_source("test/BUILD.bazel", ""); + let mut inv = umbrella_invocation(&harness, &["check", "--affected", "--output=text"]); + inv.affected = false; + inv.targets = vec!["//test:corpus".to_owned(), "//test:other".to_owned()]; + inv.affected_handoff = Some(dx_plan::args::AffectedHandoff::default()); + let (code, out, err) = harness.execute_with(&inv, &harness.runner()); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("file(s) need changes. Apply them with: dx check --apply --affected\n"), + "{err}" + ); + for stale in ["dx format --apply", "dx lint --apply", "//test:"] { + assert!(!err.contains(stale), "{stale}: {err}"); + } +} + +fn commits_harness(name: &str, config: &str, subject: &str) -> (Harness, Invocation) { + let harness = umbrella_clean(name); + let git = |args: &[&str]| { + let output = dx_process::capture::run_checked( + dx_process::capture::git(&harness.workspace) + .args(["-c", "user.name=t", "-c", "user.email=t@example.com"]) + .args(["-c", "commit.gpgsign=false"]) + .args(args), + dx_process::capture::DEFAULT_TIMEOUT, + ) + .unwrap_or_else(|error| panic!("{args:?}: {error}")); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + }; + git(&["init", "-q"]); + git(&["commit", "-q", "--allow-empty", "-m", "base"]); + let base = git(&["rev-parse", "HEAD"]); + harness.write_source("dx.toml", config); + git(&["add", "dx.toml"]); + git(&["commit", "-q", "-m", subject]); + let mut inv = umbrella_invocation(&harness, &["check", "--affected", "--output=text"]); + inv.affected = false; + inv.targets = vec!["//test:corpus".to_owned()]; + inv.affected_handoff = Some(dx_plan::args::AffectedHandoff { + base: Some(base), + ..Default::default() + }); + (harness, inv) +} + +const COMMITS_ON: &str = "schema_version = 1\n[check]\ncommits = \"conventional\"\n"; + +#[test] +fn check_affected_reports_a_commit_that_is_not_conventional() { + let (harness, inv) = commits_harness("umbrella-commits-bad", COMMITS_ON, "update stuff"); + harness.write_source("test/BUILD.bazel", ""); + let (code, out, err) = harness.execute_with(&inv, &harness.runner()); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("update stuff: not a conventional commit"), + "{err}" + ); +} + +#[test] +fn check_affected_accepts_conventional_commits() { + let (harness, inv) = commits_harness("umbrella-commits-good", COMMITS_ON, "fix: update stuff"); + harness.write_source("test/BUILD.bazel", ""); + let (code, out, err) = harness.execute_with(&inv, &harness.runner()); + assert_eq!(code, 0, "{out}{err}"); + assert!(!err.contains("conventional"), "{err}"); +} + +#[test] +fn check_affected_skips_the_commits_phase_without_the_key() { + let (harness, inv) = commits_harness( + "umbrella-commits-off", + "schema_version = 1\n", + "update stuff", + ); + harness.write_source("test/BUILD.bazel", ""); + let (code, out, err) = harness.execute_with(&inv, &harness.runner()); + assert_eq!(code, 0, "{out}{err}"); + assert!(!err.contains("conventional"), "{err}"); +} + +#[test] +fn check_affected_generate_runs_on_affected_packages() { + let harness = umbrella_clean("umbrella-affected-generate"); + harness.write_source("test/BUILD.bazel", ""); + let mut inv = umbrella_invocation(&harness, &["check", "--affected", "--output=text"]); + inv.affected = false; + inv.targets = vec!["//test:corpus".to_owned(), "//test:other".to_owned()]; + inv.affected_handoff = Some(dx_plan::args::AffectedHandoff::default()); + let (code, out, err) = harness.execute_with(&inv, &harness.runner()); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("Running generate for //test/..."), "{out}"); + assert!(!out.contains("Running generate for //..."), "{out}"); + let scopes: Vec = harness + .seen_env + .borrow() + .iter() + .flatten() + .filter(|(key, _)| key == dx_plan::plan::GENERATE_ENV_SCOPE) + .map(|(_, value)| value.clone()) + .collect(); + assert_eq!( + scopes, + vec![r#"[{"dirs":["test"],"element":"//test/..."}]"#.to_owned()], + "{out}" + ); +} + +#[test] +fn check_target_hints_name_dx_check_apply_with_the_user_patterns() { + let harness = umbrella_findings("umbrella-target-hint"); + let (code, out, err) = harness.run(&["check", "//test:corpus", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("file(s) need changes. Apply them with: dx check --apply //test:corpus\n"), + "{err}" + ); + assert!(!err.contains("dx format --apply"), "{err}"); +} + +#[test] +fn check_findings_phases_keep_their_findings_in_a_complete_report() { + let (harness, code, out, err) = check_with_failing_lint( + "umbrella-failed-lint", + &["check", "--output=json", "--report=sarif=out.sarif"], + ); + assert_eq!(code, 1, "{out}{err}"); + let events = json_events(&out); + let report = *events_of_kind(&events, "report") + .last() + .expect("umbrella report"); + assert_eq!(report["results_complete"], serde_json::json!(true)); + let finished = *events_of_kind(&events, "command_finished") + .last() + .expect("umbrella finished"); + assert_eq!(finished["exit_code"], serde_json::json!(1)); + assert_eq!(finished["results_complete"], serde_json::json!(true)); + let runs = merged_runs(&harness); + assert_eq!(runs.len(), 2, "lint and typecheck captures are retained"); + assert!( + runs[0]["results"] + .as_array() + .is_some_and(|results| !results.is_empty()), + "{runs:?}" + ); + let skipped: Vec<&str> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == "umbrella_phase_skipped") + .map(|notice| notice["correlation"].as_str().expect("correlation")) + .collect(); + assert!(skipped.is_empty(), "{skipped:?}"); + assert!( + events_of_kind(&events, "error").is_empty(), + "a findings failure is not a collection failure: {out}" + ); +} + +#[test] +fn check_failed_phase_with_a_failed_report_write_keeps_its_exit() { + let (_, code, out, err) = check_with_failing_lint( + "umbrella-failed-lint-write", + &[ + "check", + "--output=text", + "--report=sarif=missing-dir/out.sarif", + ], + ); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("report_failed"), "{err}"); +} + +#[test] +fn collector_keeps_what_a_failed_phase_wrote() { + let dir = temp_dir("umbrella-collector-failed"); + let good = dir.path().join("good.sarif"); + let bad = dir.path().join("bad.sarif"); + let missing = dir.path().join("missing.sarif"); + std::fs::write( + &good, + dx_plan::reports::render_sarif( + &["lint-tool".to_owned()], + &[], + &std::collections::BTreeMap::new(), + true, + ) + .expect("render"), + ) + .expect("good capture"); + std::fs::write(&bad, b"{").expect("bad capture"); + for (path, expected) in [ + (&good, Vec::new()), + (&bad, vec![("lint", "sarif capture is malformed")]), + (&missing, Vec::new()), + ] { + let mut collector = PhaseCollector::default(); + collector.plan(Command::Lint, "lint"); + collector.require_capture(0, path.clone()); + collector.record_exit(0, 1, PhaseKind::Tool); + collector.collect(); + assert_eq!(collector.collection_failures(), expected); + assert!(!collector.complete()); + let document: serde_json::Value = + serde_json::from_str(&collector.document()).expect("JSON"); + assert_eq!( + document["runs"].as_array().expect("runs").len(), + usize::from(path == &good) + ); + } +} + +#[test] +fn collector_ignores_a_capture_a_failed_phase_never_owed() { + let mut collector = PhaseCollector::default(); + for phase in UMBRELLA_PHASES { + collector.plan(phase, phase.name()); + } + collector.record_exit(0, 0, PhaseKind::Clean); + collector.require_capture(1, PathBuf::from("/nonexistent/format.sarif")); + collector.record_exit(1, 1, PhaseKind::Tool); + collector.skip_remaining(String::new()); + collector.collect(); + assert_eq!(collector.collection_failures(), Vec::new()); + assert_eq!( + collector.reasons(), + vec![ + "format failed".to_owned(), + "lint skipped".to_owned(), + "typecheck skipped".to_owned(), + ] + ); + assert_eq!(collector.skipped(), vec!["lint", "typecheck"]); + collector.cleanup(); +} + +#[test] +fn check_stop_after_findings_keeps_both_facts() { + let mut harness = umbrella_findings("umbrella-stop-after-findings"); + harness.free_disk_bytes = Some(1 << 20); + let runner = FailFrom { + inner: harness.runner(), + from: 3, + to: None, + code: 36, + launches: Cell::new(0), + }; + let inv = umbrella_invocation( + &harness, + &["check", "--output=text", "--report=sarif=out.sarif"], + ); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 36, "{out}{err}"); + assert!( + err.contains( + "dx: phases: generate failed, format findings, lint findings, typecheck failed" + ), + "{err}" + ); + assert!( + out.contains("(incomplete: generate failed, typecheck failed)"), + "{out}" + ); + assert_eq!(harness.seen_env.borrow().len(), 4, "every phase starts"); + assert_eq!( + merged_runs(&harness).len(), + 1, + "lint merges and typecheck never wrote a capture" + ); +} + +#[test] +fn check_later_clean_phases_never_clear_earlier_findings() { + let harness = umbrella_findings("umbrella-later-clean"); + let (artifact, _) = harness + .staged_artifacts() + .into_iter() + .next() + .expect("artifact"); + let clean = harness.valid_result(vec![], vec![]); + let witness = intended_witness("check", true, "", ""); + let mut staged = staged_runner(&harness, vec![(artifact, clean)], Some(witness), 2); + staged.apply_intended = Some(intended_witness("check", true, "", "")); + let inv = umbrella_invocation(&harness, &["check", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &staged); + assert_eq!(code, 1, "{out}{err}"); + let events = json_events(&out); + let findings: Vec<&str> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == "umbrella_phase_findings") + .map(|notice| notice["correlation"].as_str().expect("correlation")) + .collect(); + assert_eq!(findings, vec!["check/format"]); + let finished = *events_of_kind(&events, "command_finished") + .last() + .expect("umbrella finished"); + assert_eq!(finished["exit_code"], serde_json::json!(1)); + assert_eq!(finished["results_complete"], serde_json::json!(true)); +} + +#[test] +fn check_below_threshold_findings_keep_every_phase_clean() { + let mut harness = umbrella_clean("umbrella-below-threshold"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![Harness::diagnostic("unused", true)], vec![]), + ); + let (code, out, err) = harness.run(&["check", "--output=text", "--fail-on=error"]); + assert_eq!(code, 0, "{out}{err}"); + assert!(!err.contains("dx: phases"), "{err}"); + assert_eq!(harness.seen_env.borrow().len(), 4); +} + +#[test] +fn check_stops_on_a_rejected_scope_before_any_later_phase() { + let harness = umbrella_findings("umbrella-bad-scope"); + let (code, out, err) = harness.run(&["check", "--output=text", "no/such/dir"]); + assert_eq!(code, 2, "{out}{err}"); + assert!( + err.contains("dx: phases: generate failed, format skipped"), + "{err}" + ); + assert!(harness.seen_env.borrow().is_empty(), "{out}{err}"); +} + +#[test] +fn check_reports_a_generate_exit_without_changes_as_a_tool_failure() { + let mut harness = umbrella_clean("umbrella-generate-tool-failure"); + harness.generate_code = Some(1); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("generate failed") && !err.contains("generate findings"), + "{out}{err}" + ); +} + +#[test] +fn check_reports_a_generate_exit_with_changes_but_no_patch_as_a_tool_failure() { + let mut harness = umbrella_clean("umbrella-generate-no-patch"); + harness.generate_code = Some(1); + harness.intended = Some(intended_witness( + "check", + true, + &intended_modify("src/BUILD.bazel", b"old\n", b"new\n"), + "", + )); + let (code, out, err) = harness.run(&["check", "--output=text"]); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("generate failed") && !err.contains("generate findings"), + "{out}{err}" + ); +} + +#[test] +fn check_reports_generate_drift_first_and_runs_every_phase() { + let mut harness = umbrella_clean("umbrella-generate-drift"); + harness.generate_code = Some(1); + harness.patch = Some(b"--- a\n+++ b\n".to_vec()); + harness.intended = Some(intended_witness( + "check", + true, + &intended_modify("src/BUILD.bazel", b"old\n", b"new\n"), + "", + )); + let (code, out, err) = harness.run(&["check", "--output=json"]); + assert_eq!(code, 1, "{out}{err}"); + let events = json_events(&out); + let finished: Vec<(String, i64)> = events_of_kind(&events, "phase_finished") + .iter() + .map(|event| { + ( + event["phase"].as_str().expect("phase").to_owned(), + event["exit_code"].as_i64().expect("exit"), + ) + }) + .collect(); + assert_eq!( + finished, + [ + ("generate".to_owned(), 1), + ("format".to_owned(), 0), + ("lint".to_owned(), 0), + ("typecheck".to_owned(), 0) + ] + ); + let changes = events_of_kind(&events, "change"); + assert_eq!(changes.len(), 1, "{out}"); + assert_eq!(changes[0]["path"], "src/BUILD.bazel"); + assert_eq!(changes[0]["effect"], "proposed"); + assert!(events_of_kind(&events, "mutation").is_empty(), "{out}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"x = 1\n" + ); + assert!(!harness.workspace.join("src/BUILD.bazel").exists()); + let last = events.last().expect("last"); + assert_eq!(last["exit_code"], 1, "{out}"); + assert_eq!(last["results_complete"], true, "{out}"); +} + +/// Plays Gazelle for `check --apply`: the first generate launch writes +/// `fixed` to `build` and its apply witness, later generate launches +/// report a clean check, and quality launches fail to compile until +/// `build` lists `source`. +struct StaleBuild { + inner: R, + build: PathBuf, + fixed: Vec, + source: &'static str, + apply_witness: Vec, + bep_line: String, + generate_launches: Cell, +} + +impl Runner for StaleBuild { + fn free_disk_bytes(&self, path: &Path) -> Option { + self.inner.free_disk_bytes(path) + } + + fn run(&self, argv: &[String], cwd: &Path, env: &[(&str, &str)]) -> io::Result { + let status = self.inner.run(argv, cwd, env)?; + if is_shard_probe(argv) { + return Ok(status); + } + if let Some(path) = env + .iter() + .find_map(|(key, value)| (*key == GENERATE_ENV_INTENDED).then_some(*value)) + { + let launch = self.generate_launches.get(); + self.generate_launches.set(launch + 1); + if launch == 0 { + std::fs::write(&self.build, &self.fixed).expect("BUILD"); + std::fs::write(path, &self.apply_witness).expect("witness"); + } else { + std::fs::write(path, intended_witness("check", true, "", "")).expect("witness"); + } + return Ok(status); + } + let build = std::fs::read_to_string(&self.build).unwrap_or_default(); + if build.contains(self.source) { + return Ok(status); + } + if let Some(bep) = argv + .iter() + .find_map(|arg| arg.strip_prefix("--build_event_json_file=")) + { + std::fs::write(bep, format!("{}\n{LAST_EVENT}", self.bep_line)).expect("BEP"); + } + Ok(ChildStatus::exited(1)) + } +} + +/// The phase names a JSON umbrella run started, in order. +fn started_phases(events: &[serde_json::Value]) -> Vec { + events_of_kind(events, "phase_started") + .iter() + .map(|event| event["phase"].as_str().expect("phase").to_owned()) + .collect() +} + +/// Every generate witness path the runner saw, in launch order. +fn intended_paths(harness: &Harness) -> Vec { + harness + .seen_env + .borrow() + .iter() + .flatten() + .filter(|(key, _)| key == GENERATE_ENV_INTENDED) + .map(|(_, value)| value.clone()) + .collect() +} + +#[test] +fn fix_apply_generates_srcs_before_quality_builds_a_new_source() { + let mut harness = umbrella_clean("umbrella-fix-new-source"); + let stale = b"rust_library(srcs = [\"lib.rs\"])\n"; + let fixed = b"rust_library(srcs = [\"foo.rs\", \"lib.rs\"])\n".to_vec(); + harness.write_source("src/foo.rs", "pub fn foo() {}\n"); + harness.write_source("src/BUILD.bazel", std::str::from_utf8(stale).expect("utf8")); + harness.intended = None; + let uri = stderr_file(&harness, "foo-rustc", RENDERED_E0308); + let runner = StaleBuild { + inner: harness.runner(), + build: harness.workspace.join("src/BUILD.bazel"), + fixed: fixed.clone(), + source: "foo.rs", + apply_witness: intended_witness( + "default", + true, + &intended_modify("src/BUILD.bazel", stale, &fixed), + "", + ), + bep_line: failed_action("//src:lib", "c1", "Rustc", "out/lib.rlib", &uri), + generate_launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/BUILD.bazel")).expect("BUILD"), + fixed + ); + let events = json_events(&out); + assert_eq!( + started_phases(&events), + vec!["generate", "format", "lint", "typecheck"], + "{out}" + ); + assert_eq!(runner.generate_launches.get(), 2, "apply plus its re-check"); +} + +#[test] +fn fix_apply_reruns_generate_only_after_a_quality_write() { + let harness = partial_failure_harness("umbrella-fix-rerun-json"); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + let verify_files: Vec<(PathBuf, Vec)> = harness + .staged_artifacts() + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + let (code, out, err) = fix_apply_format_edit_then_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=json"], + ); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + assert_eq!( + started_phases(&events), + vec!["generate", "format", "lint", "typecheck", "rerun/generate"], + "{out}" + ); + let rerun: Vec<&serde_json::Value> = events + .iter() + .filter(|event| event["correlation"] == "check/rerun/generate") + .collect(); + assert!(!rerun.is_empty(), "{out}"); + assert!( + rerun.iter().all(|event| event["phase"] == "rerun/generate"), + "{out}" + ); + assert!( + events_of_kind(&events, "notice") + .iter() + .all(|notice| notice["code"] != "umbrella_phase_skipped"), + "{out}" + ); +} + +#[test] +fn fix_apply_without_a_write_runs_generate_once() { + let mut clean = umbrella_clean("umbrella-no-rerun-clean"); + clean.intended = Some(intended_witness("default", true, "", "")); + let refused = refused_format("umbrella-no-rerun-refused"); + let findings = umbrella_findings("umbrella-no-rerun-fix"); + let checked = umbrella_findings("umbrella-no-rerun-check"); + for (harness, words) in [ + (&clean, &["check", "--apply", "--output=json"][..]), + (&refused, &["check", "--apply", "--output=json"][..]), + (&findings, &["check", "--output=json"][..]), + (&checked, &["check", "--output=json"][..]), + ] { + let (_, out, err) = harness.run(words); + let events = json_events(&out); + let started = started_phases(&events); + assert_eq!( + &started[..4], + ["generate", "format", "lint", "typecheck"], + "{words:?}: {out}{err}" + ); + assert!( + !started.iter().any(|phase| phase == "rerun/generate"), + "{words:?}: {out}" + ); + assert!(!out.contains("rerun/generate"), "{words:?}: {out}"); + } +} + +#[test] +fn check_apply_reruns_generate_after_a_lint_compile_error() { + let harness = partial_failure_harness("umbrella-fix-stop-no-rerun"); + let mut staged = staged_runner( + &harness, + harness.staged_artifacts(), + Some(intended_witness("check", true, "", "")), + 5, + ); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + staged.clean_files = staged + .apply_files + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + staged.clean_from = 1; + let uri = stderr_file(&harness, "lint-clippy", RENDERED_E0308); + let runner = CompileFailAt { + inner: staged, + at: 2, + bep_line: failed_action("//app:bin", "c1", "Clippy", "out/bin.clippy", &uri), + launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=json"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("src/a.py")).expect("source"), + b"y = 1\n", + "format applied before lint stopped the run" + ); + let events = json_events(&out); + assert_eq!( + started_phases(&events), + vec!["generate", "format", "lint", "rerun/generate"], + "{out}" + ); + let skipped: Vec<&str> = events_of_kind(&events, "notice") + .iter() + .filter(|notice| notice["code"] == "umbrella_phase_skipped") + .filter_map(|notice| notice["message"].as_str()) + .collect(); + assert_eq!( + skipped, + vec!["typecheck skipped after lint failed to compile"] + ); + assert_eq!(runner.launches.get(), 4, "{out}"); +} + +#[test] +fn fix_apply_verification_nonces_differ_from_the_rerun() { + let harness = partial_failure_harness("umbrella-fix-rerun-nonces"); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + let verify_files: Vec<(PathBuf, Vec)> = harness + .staged_artifacts() + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + let (code, out, err) = fix_apply_format_edit_then_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=text"], + ); + assert_eq!(code, 0, "{out}{err}"); + let paths = intended_paths(&harness); + assert_eq!( + paths.len(), + 3, + "generate, rerun and verify/generate: {paths:?}" + ); + let distinct: std::collections::BTreeSet<&String> = paths.iter().collect(); + assert_eq!(distinct.len(), paths.len(), "{paths:?}"); +} + +#[test] +fn fix_apply_failed_rerun_is_not_rechecked() { + let harness = partial_failure_harness("umbrella-fix-rerun-fails"); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + let mut staged = staged_runner( + &harness, + harness + .staged_artifacts() + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(), + Some(intended_witness("check", true, "", "")), + 5, + ); + staged.clean_files = staged + .apply_files + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + staged.clean_from = 1; + let runner = FailFrom { + inner: staged, + from: 4, + to: Some(5), + code: 1, + launches: Cell::new(0), + }; + let inv = umbrella_invocation(&harness, &["check", "--apply", "--output=text"]); + let (code, out, err) = harness.execute_with(&inv, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains( + "verification passed after apply for format; not re-checked, \ + failed during apply: rerun/generate" + ), + "{err}" + ); + assert!(err.contains("rerun/generate failed"), "{err}"); + assert_eq!(runner.launches.get(), 6, "{out}{err}"); +} + +#[test] +fn phase_kind_stops_on_selection_host_cancel_or_failed_generate() { + for kind in [ + PhaseKind::Clean, + PhaseKind::Findings, + PhaseKind::NonConvergence, + PhaseKind::Tool, + PhaseKind::Incomplete, + PhaseKind::Report, + ] { + assert!(kind.continues(false), "{kind:?}"); + assert!(kind.continues(true), "{kind:?}"); + assert!(!kind.stop_reason().is_empty()); + } + for kind in [ + PhaseKind::Selection, + PhaseKind::Host, + PhaseKind::Cancelled, + PhaseKind::GenerateFailed, + ] { + assert!(!kind.continues(false), "{kind:?}"); + assert!(!kind.continues(true), "{kind:?}"); + assert!(!kind.stop_reason().is_empty()); + } + assert!(PhaseKind::Compile.continues(false)); + assert!(!PhaseKind::Compile.continues(true)); + assert_eq!(PhaseKind::Compile.stop_reason(), "failed to compile"); +} + +#[test] +fn parse_capture_keeps_only_well_formed_documents() { + let document = dx_plan::reports::render_sarif( + &["lint-tool".to_owned()], + &[], + &std::collections::BTreeMap::new(), + true, + ) + .expect("render"); + let parsed = parse_capture(document.as_bytes()).expect("round trip"); + assert_eq!(parsed["version"], serde_json::json!("2.1.0")); + assert_eq!(parse_capture(b" "), Err(CaptureState::Malformed)); + assert_eq!(parse_capture(b"null"), Err(CaptureState::Malformed)); + assert_eq!( + parse_capture(br#"{"runs":[],"$schema":"x"}"#), + Err(CaptureState::Malformed), + "a run needs a tool" + ); + assert_eq!( + parse_capture(br#"{"runs":[]}"#), + Err(CaptureState::Malformed), + "a document without a version is not SARIF 2.1.0" + ); +} + +fn graph_calls(harness: &Harness) -> usize { + harness + .query + .calls + .borrow() + .iter() + .filter(|argv| argv.last().map(String::as_str) == Some("kind('rule', //...)")) + .count() +} + +fn pkg_harness(name: &str) -> Harness { + let harness = umbrella_clean(name); + harness.write_source("pkg/BUILD.bazel", ""); + harness.write_source("pkg/a.py", "x = 1\n"); + harness +} + +#[test] +fn check_loads_the_rule_graph_once_across_phases() { + let harness = pkg_harness("umbrella-graph-once-check"); + harness.query.script_graph(&[ + graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), + graph_rule("//pkg:extra", &["//pkg:a.py"], &[]), + ]); + let (code, out, err) = harness.run(&["check", "--output=text", "pkg/a.py"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(harness.seen_env.borrow().len(), 4, "every phase ran"); + assert_eq!( + out.matches("for //pkg:extra //pkg:lib").count(), + 3, + "every analysis phase plans the same owners: {out}" + ); + assert!(out.contains("Running generate for //pkg/..."), "{out}"); + assert_eq!(graph_calls(&harness), 1, "one graph load for the run"); +} + +#[test] +fn fix_without_changes_loads_the_rule_graph_once() { + let harness = pkg_harness("umbrella-graph-once-fix"); + harness + .query + .script_graph(&[graph_rule("//pkg:lib", &["//pkg:a.py"], &[])]); + let (code, out, err) = harness.run(&["check", "--output=text", "pkg/a.py"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(graph_calls(&harness), 1, "default fix never writes"); +} + +#[test] +fn fix_apply_reloads_the_rule_graph_after_a_write() { + let mut harness = pkg_harness("umbrella-graph-reload"); + harness.results.insert( + "//test:corpus".to_owned(), + harness.valid_result(vec![], vec![harness.replacement_in("pkg/a.py", b"y")]), + ); + harness.intended = Some(intended_witness("default", true, "", "")); + harness + .query + .script_graph(&[graph_rule("//pkg:lib", &["//pkg:a.py"], &[])]); + harness.query.script_graph(&[ + graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), + graph_rule("//pkg:new", &["//pkg:a.py"], &[]), + ]); + harness.query.script_graph(&[ + graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), + graph_rule("//pkg:new", &["//pkg:a.py"], &[]), + ]); + harness.query.script_graph(&[ + graph_rule("//pkg:lib", &["//pkg:a.py"], &[]), + graph_rule("//pkg:new", &["//pkg:a.py"], &[]), + ]); + let staged = harness.staged_artifacts(); + let clean = harness.result_full(vec![], vec![], vec![], vec![]); + let verify_files: Vec<(PathBuf, Vec)> = staged + .iter() + .map(|(path, _)| (path.clone(), clean.clone())) + .collect(); + let (_, out, err) = fix_apply_with_staged_verify( + &harness, + verify_files, + Some(intended_witness("check", true, "", "")), + 5, + &["check", "--apply", "--output=text", "pkg/a.py"], + ); + assert!( + graph_calls(&harness) >= 2, + "a phase that applied edits invalidates the graph: {out}{err}" + ); + assert!( + out.contains("//pkg:new"), + "later plans see the new owner: {out}" + ); +} diff --git a/cli/cli/src/exec/update_consumer.rs b/cli/cli/src/exec/update_consumer.rs index 7c7cbc890f..4975bf75f4 100644 --- a/cli/cli/src/exec/update_consumer.rs +++ b/cli/cli/src/exec/update_consumer.rs @@ -658,2450 +658,5 @@ fn success_line(set: &ResolvedSet, request: &Selection) -> String { } #[cfg(test)] -mod tests { - #[test] - fn stop_of_reports_the_holder_once_then_the_signal() { - let (mut err, mut events) = (Vec::new(), Vec::new()); - let mut gate = super::SlotGate { - err: &mut err, - events: &mut events, - phase: dx_plan::slots::Phase::new(dx_plan::args::Command::Update), - busy: Some("pid 7".to_owned()), - }; - assert_eq!( - super::stop_of(&mut gate, 1), - dx_plan::slots::Stop::Busy("pid 7".to_owned()) - ); - assert_eq!( - super::stop_of(&mut gate, 130), - dx_plan::slots::Stop::Signal(130) - ); - } - - use super::super::super::test_support::Harness; - use super::super::super::test_support::{event, event_kinds, json_events}; - use super::super::update_live::{Launch, ScriptRunner, fake_pinned}; - - fn uv_call(check: bool, dir: &str) -> Vec { - let mut argv = vec![fake_pinned("uv"), "lock".to_owned()]; - if check { - argv.push("--check".to_owned()); - } - for word in [ - "--directory", - dir, - "--no-config", - "--no-cache", - "--no-python-downloads", - "--python", - ] { - argv.push(word.to_owned()); - } - argv.push(fake_pinned("python3")); - argv - } - - const UV_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\nuv.project(hub_name = \"deps\", pyproject = \"//services/worker:pyproject.toml\", lock = \"//services/worker:uv.lock\")\n"; - - const PYPROJECT: &str = r#" -[project] -name = "project" -version = "0.1.0" -requires-python = ">=3.9" -dependencies = ["anyio>=4"] -"#; - - const UV_LOCK: &str = r#" -version = 1 -requires-python = ">=3.9" - -[[package]] -name = "anyio" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -"#; - - fn consumer_harness(name: &str, module: &str, with_locks: bool) -> Harness { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let id = COUNTER.fetch_add(1, Ordering::SeqCst); - let harness = Harness::new(&format!("update-consumer-{name}-{id}")); - harness.write_source("MODULE.bazel", module); - harness.write_source("apps/frontend/pyproject.toml", PYPROJECT); - harness.write_source("services/worker/pyproject.toml", PYPROJECT); - if with_locks { - harness.write_source("apps/frontend/uv.lock", UV_LOCK); - harness.write_source("services/worker/uv.lock", UV_LOCK); - } - harness - } - - fn run_with(argv: &[&str], runner: &ScriptRunner, harness: &Harness) -> (i32, String, String) { - let words: Vec = argv.iter().map(|word| (*word).to_owned()).collect(); - let invocation = dx_plan::args::parse(&words).expect("parse"); - harness.execute_with(&invocation, runner) - } - - fn run_consumer(argv: &[&str], runner: &ScriptRunner) -> (i32, String, String, Harness) { - let harness = consumer_harness("sets", UV_MODULE, true); - let (code, out, err) = run_with(argv, runner, &harness); - (code, out, err, harness) - } - - #[test] - fn consumer_bare_check_runs_configured_sets_and_no_phantoms() { - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update"], &runner); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("Running update --check for deps-frontend, deps-worker"), - "{out}" - ); - assert!( - out.contains("deps-frontend lockfile current (apps/frontend/uv.lock)"), - "{out}" - ); - assert!( - out.contains("deps-worker lockfile current (services/worker/uv.lock)"), - "{out}" - ); - for phantom in [ - "cargo", - "pnpm", - "maven", - "nuget", - "examples/", - "third_party/", - "quality/tools", - ] { - assert!(!out.contains(phantom), "phantom {phantom} in {out}"); - } - assert_eq!(err, "", "{err}"); - assert_eq!( - *runner.calls.borrow(), - vec![ - uv_call(true, "apps/frontend"), - uv_call(true, "services/worker") - ] - ); - assert_eq!(runner.hermetic_envs.borrow().len(), 2); - for env in runner.hermetic_envs.borrow().iter() { - assert!( - env.iter().all(|(key, _)| cfg!(windows) - && ["SYSTEMROOT", "TEMP", "TMP", "USERPROFILE"].contains(&key.as_str())), - "{env:?}" - ); - } - } - - #[test] - fn consumer_without_pinned_uv_is_unavailable_and_never_clean() { - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let (code, out, err, _) = run_consumer(&["update"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("backend_unavailable"), "{err}"); - assert!(err.contains("DX_UV_BIN"), "{err}"); - assert!(err.contains("PATH is never searched"), "{err}"); - assert!(!out.contains("lockfile current"), "{out}"); - assert!(runner.calls.borrow().is_empty()); - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let (code, out, err, _) = run_consumer(&["update", "--apply", "deps-worker"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("backend_unavailable"), "{err}"); - assert!(!out.contains("updated deps-worker"), "{out}"); - assert!(runner.calls.borrow().is_empty()); - } - - /// Scripts one Bazel resolution of `rel` under a fresh output base. - fn script_resolution(harness: &Harness, base: &std::path::Path, rel: &str) { - let file = base.join(rel); - std::fs::create_dir_all(file.parent().expect("parent")).expect("dir"); - std::fs::write(&file, b"").expect("tool"); - harness - .query - .outputs - .borrow_mut() - .push(dx_plan::resolve::QueryResult { - code: Some(0), - stdout: format!("{rel}\n").into_bytes(), - stderr: Vec::new(), - }); - harness.query.script_info(&base.to_string_lossy()); - } - - #[test] - fn consumer_unset_uv_resolves_through_bazel_once_for_every_set() { - let base = super::super::super::test_support::temp_dir("uv-fallback"); - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let harness = consumer_harness("fallback", UV_MODULE, true); - script_resolution(&harness, base.path(), "external/x/uv"); - script_resolution(&harness, base.path(), "external/x/python3"); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - let calls = harness.query.calls.borrow(); - assert_eq!(calls.len(), 2, "one cquery per tool: {calls:?}"); - assert!(calls[0].contains(&"@dx_tools//:uv".to_owned()), "{calls:?}"); - assert!( - !calls[0] - .iter() - .any(|arg| arg.contains("repository_disable")) - ); - let launched = runner.calls.borrow(); - assert_eq!(launched.len(), 2, "{launched:?}"); - let uv = base.path().join("external").join("x").join("uv"); - assert_eq!(launched[0][0], uv.to_string_lossy()); - assert_eq!(launched[1][0], uv.to_string_lossy()); - } - - #[test] - fn consumer_offline_check_disables_repository_downloads_for_resolution() { - let base = super::super::super::test_support::temp_dir("uv-offline"); - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let harness = consumer_harness("offline", UV_MODULE, true); - script_resolution(&harness, base.path(), "external/x/uv"); - script_resolution(&harness, base.path(), "external/x/python3"); - let (code, out, err) = run_with(&["update", "--offline"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - let calls = harness.query.calls.borrow(); - assert!(!calls.is_empty()); - for call in calls.iter() { - assert!( - call.contains(&"--repository_disable_download".to_owned()), - "{call:?}" - ); - assert!(!call.contains(&"--nofetch".to_owned()), "{call:?}"); - } - } - - #[test] - fn consumer_failed_resolution_is_unavailable_with_the_detail_and_queried_once() { - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let harness = consumer_harness("resolve-fails", UV_MODULE, true); - harness - .query - .script_failure(1, "ERROR: no such repository '@dx_tools'\n"); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("backend_unavailable"), "{err}"); - assert!(err.contains("DX_UV_BIN"), "{err}"); - assert!(err.contains("PATH is never searched"), "{err}"); - assert!(err.contains("no such repository"), "{err}"); - assert_eq!(harness.query.calls.borrow().len(), 1); - assert!(runner.calls.borrow().is_empty()); - } - - #[test] - fn consumer_resolved_uv_with_the_wrong_version_is_rejected() { - let base = super::super::super::test_support::temp_dir("uv-wrong-version"); - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - runner.version = Some("uv 0.1.0".to_owned()); - let harness = consumer_harness("wrong-version", UV_MODULE, true); - script_resolution(&harness, base.path(), "external/x/uv"); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("backend_unavailable"), "{err}"); - assert!(runner.calls.borrow().is_empty()); - } - - #[test] - fn consumer_rejected_applies_never_query_bazel() { - for flag in ["--offline", "--frozen"] { - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let harness = consumer_harness("rejected", UV_MODULE, true); - let (_, out, err) = run_with( - &["update", "--apply", flag, "deps-worker"], - &runner, - &harness, - ); - assert!( - harness.query.calls.borrow().is_empty(), - "{flag}: {out}{err}" - ); - assert!(harness.query.info_calls.borrow().is_empty()); - } - } - - #[test] - fn consumer_backend_unavailable_reports_its_code() { - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let (code, out, err, _) = run_consumer(&["update", "--output=json"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(out.contains("\"code\":\"backend_unavailable\""), "{out}"); - assert!(!out.contains("\"code\":\"update_failed\""), "{out}"); - for args in [&["update"][..], &["update", "--apply", "deps-worker"][..]] { - let mut runner = ScriptRunner::new(&[]); - runner.pinned = false; - let (code, out, err, _) = run_consumer(args, &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("dx: backend_unavailable:"), "{err}"); - assert!(!err.contains("dx: update_failed:"), "{err}"); - } - } - - #[test] - fn consumer_uv_of_another_version_is_unavailable_and_never_clean() { - for version in [None, Some("uv 0.11.0".to_owned())] { - let mut runner = ScriptRunner::new(&[]); - runner.version = version; - let (code, out, err, _) = run_consumer(&["update"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("backend_unavailable"), "{err}"); - assert!(err.contains("uv 0.13.7"), "{err}"); - assert!(!out.contains("lockfile current"), "{out}"); - assert!(runner.calls.borrow().is_empty()); - } - } - - #[test] - fn consumer_failed_uv_fails_its_set_and_keeps_the_others() { - let runner = ScriptRunner::new(&[("consumer-uv:apps/frontend", Some(1))]); - let (code, out, err, _) = run_consumer(&["update"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("failed to check deps-frontend: updater exited 1"), - "{err}" - ); - assert!( - out.contains("deps-worker lockfile current (services/worker/uv.lock)"), - "{out}" - ); - assert!(!out.contains("deps-frontend lockfile current"), "{out}"); - assert_eq!(runner.calls.borrow().len(), 2); - let runner = ScriptRunner::new(&[("consumer-uv:services/worker", None)]); - let (code, out, err, _) = run_consumer(&["update", "--apply", "deps-worker"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("failed to update deps-worker: updater terminated by signal"), - "{err}" - ); - let mut runner = ScriptRunner::new(&[]); - runner.io_error = true; - let (code, out, err, _) = run_consumer(&["update", "deps-worker"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("failed to launch updater"), "{err}"); - } - - #[test] - fn consumer_uv_settings_outside_the_closure_are_unsupported() { - let harness = consumer_harness("settings", UV_MODULE, true); - harness.write_source( - "apps/frontend/uv.toml", - "exclude-newer = \"2020-01-01T00:00:00Z\"\n", - ); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("apps/frontend/uv.toml exists"), "{err}"); - assert!( - out.contains("deps-worker lockfile current (services/worker/uv.lock)"), - "{out}" - ); - assert_eq!( - *runner.calls.borrow(), - vec![uv_call(true, "services/worker")] - ); - let harness = consumer_harness("tool-uv", UV_MODULE, true); - harness.write_source( - "services/worker/pyproject.toml", - &format!("{PYPROJECT}\n[tool.uv]\nindex-url = \"https://example.invalid/simple\"\n"), - ); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--apply", "deps-worker"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(err.contains("[tool.uv] index-url"), "{err}"); - assert!(runner.calls.borrow().is_empty()); - } - - #[test] - fn consumer_partial_update_restores_its_own_locks() { - let runner = ScriptRunner::new(&[("consumer-uv:services/worker", Some(1))]); - let (code, out, err, _) = run_consumer(&["update", "--apply"], &runner); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("update_recovery"), "{err}"); - assert!(err.contains("dx update --apply deps-worker"), "{err}"); - assert!( - err.contains("git checkout -- apps/frontend/uv.lock"), - "{err}" - ); - assert!(!err.contains("nothing to roll back"), "{err}"); - } - - #[test] - fn consumer_selective_update_runs_one_directory() { - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update", "--apply", "deps-worker"], &runner); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("Running update for deps-worker"), "{out}"); - assert!( - out.contains("updated deps-worker (services/worker/uv.lock)"), - "{out}" - ); - assert!(!out.contains("deps-frontend"), "{out}"); - assert_eq!(err, "", "{err}"); - assert_eq!( - *runner.calls.borrow(), - vec![uv_call(false, "services/worker")] - ); - } - - #[test] - fn consumer_scopes_resolve_to_their_owning_sets() { - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update", "apps/frontend/app/main.py"], &runner); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(runner.calls.borrow().len(), 1); - assert!(runner.calls.borrow()[0].contains(&"apps/frontend".to_owned())); - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update", "//..."], &runner); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(runner.calls.borrow().len(), 2); - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update", "//services/worker/..."], &runner); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(runner.calls.borrow().len(), 1); - assert!(runner.calls.borrow()[0].contains(&"services/worker".to_owned())); - } - - #[test] - fn consumer_unknown_and_unowned_selectors_fail_closed() { - let runner = ScriptRunner::new(&[]); - let (code, _, err, _) = run_consumer(&["update", "--apply", "cargo"], &runner); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("unknown dependency set or scope"), "{err}"); - let runner = ScriptRunner::new(&[]); - let (code, _, err, _) = run_consumer(&["update", "--apply", "docs/cli/README.md"], &runner); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("no owning dependency set"), "{err}"); - assert_eq!(runner.calls.borrow().len(), 0); - } - - #[test] - fn consumer_selective_package_is_unsupported_without_launch() { - let runner = ScriptRunner::new(&[]); - let (code, _, err, _) = - run_consumer(&["update", "--apply", "deps-frontend:anyio"], &runner); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(err.contains("deps-frontend"), "{err}"); - assert_eq!(runner.calls.borrow().len(), 0); - } - - #[test] - fn consumer_missing_lock_fails_check_without_launch() { - let harness = consumer_harness("missing-lock", UV_MODULE, false); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with(&["update", "deps-worker"], &runner, &harness); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("services/worker/uv.lock"), "{err}"); - assert!(err.contains("is missing"), "{err}"); - assert_eq!(runner.calls.borrow().len(), 0); - } - - #[test] - fn consumer_check_leaves_the_workspace_untouched() { - let runner = ScriptRunner::new(&[]); - let (code, _, _, harness) = run_consumer(&["update"], &runner); - assert_eq!(code, 0); - assert_eq!( - std::fs::read(harness.workspace.join("apps/frontend/uv.lock")).expect("lock"), - UV_LOCK.as_bytes() - ); - assert_eq!( - std::fs::read(harness.workspace.join("apps/frontend/pyproject.toml")) - .expect("manifest"), - PYPROJECT.as_bytes() - ); - assert_eq!( - std::fs::read(harness.workspace.join("MODULE.bazel")).expect("module"), - UV_MODULE.as_bytes() - ); - } - - #[test] - fn consumer_offline_update_needs_network() { - let runner = ScriptRunner::new(&[]); - let (code, _, err, _) = - run_consumer(&["update", "--apply", "--offline", "deps-worker"], &runner); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - assert_eq!(runner.calls.borrow().len(), 0); - } - - #[test] - fn consumer_frozen_update_locks_resolution() { - let runner = ScriptRunner::new(&[]); - let (code, _, err, _) = - run_consumer(&["update", "--apply", "--frozen", "deps-worker"], &runner); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - assert!(!err.contains("offline_required"), "{err}"); - assert_eq!(runner.calls.borrow().len(), 0); - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update", "--frozen", "deps-worker"], &runner); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("deps-worker lockfile current (services/worker/uv.lock)"), - "{out}{err}" - ); - } - - #[test] - fn consumer_invalid_config_fails_before_any_backend() { - let harness = consumer_harness( - "invalid", - "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\")\n", - true, - ); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("uv.project"), "{err}"); - assert_eq!(runner.calls.borrow().len(), 0); - } - - #[test] - fn consumer_json_check_reports_per_set_correlations() { - let runner = ScriptRunner::new(&[]); - let (code, out, err, _) = run_consumer(&["update", "--output=json"], &runner); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let kinds = event_kinds(&events); - assert_eq!(kinds[0], "command_started"); - assert_eq!(kinds[kinds.len() - 1], "command_finished"); - assert!( - out.contains("\"correlation\":\"update:deps-frontend\""), - "{out}" - ); - assert!( - out.contains("\"correlation\":\"update:deps-worker\""), - "{out}" - ); - let _ = event(&events, "command_started"); - } - - const MIXED_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\nnpm = use_extension(\"@aspect_rules_js//npm:extensions.bzl\", \"npm\")\nnpm.npm_translate_lock(name = \"jobs\", pnpm_lock = \"//consumer-npm/jobs:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"web\", pnpm_lock = \"//consumer-npm/web:pnpm-lock.yaml\")\n"; - - const PACKAGE_JSON: &str = "{\n \"name\": \"app\",\n \"version\": \"1.0.0\"\n}\n"; - - const PNPM_LOCK: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n"; - - fn mixed_harness(name: &str) -> Harness { - let harness = consumer_harness(name, MIXED_MODULE, true); - for dir in ["consumer-npm/jobs", "consumer-npm/web"] { - harness.write_source(&format!("{dir}/package.json"), PACKAGE_JSON); - harness.write_source(&format!("{dir}/pnpm-lock.yaml"), PNPM_LOCK); - } - harness - } - - fn npm_env() -> Vec<(String, String)> { - let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; - vec![ - ("npm_config_globalconfig".to_owned(), null.to_owned()), - ("npm_config_userconfig".to_owned(), null.to_owned()), - ] - } - - #[allow(clippy::disallowed_methods)] // test helper mirrors the backend argv - fn npm_call( - harness: &Harness, - startup: &[String], - offline: bool, - dir: &str, - command: &[&str], - ) -> Vec { - let mut argv = dx_process::startup_argv(startup); - argv.push("run".to_owned()); - argv.extend(dx_process::build_verb_options( - "run", - &["--config=dx_build_dev".to_owned()], - )); - argv.push("--config=dx_build_dev".to_owned()); - argv.extend(dx_plan::plan::shared_settings("run")); - if offline { - argv.push("--repository_disable_download".to_owned()); - } - for word in ["@pnpm//:pnpm", "--", "--dir"] { - argv.push(word.to_owned()); - } - argv.push(harness.workspace.join(dir).to_string_lossy().into_owned()); - argv.extend(command.iter().map(|word| (*word).to_owned())); - argv - } - - fn npm_check(harness: &Harness, dir: &str) -> Vec { - npm_call( - harness, - &[], - false, - dir, - &["install", "--lockfile-only", "--frozen-lockfile"], - ) - } - - fn read_bytes(harness: &Harness, rel: &str) -> Vec { - std::fs::read(harness.workspace.join(rel)).unwrap_or_else(|error| panic!("{rel}: {error}")) - } - - #[test] - fn consumer_mixed_check_runs_uv_hermetic_and_npm_through_bazel() { - let harness = mixed_harness("mixed-check"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("jobs lockfile current (consumer-npm/jobs/pnpm-lock.yaml)"), - "{out}" - ); - assert!( - out.contains("web lockfile current (consumer-npm/web/pnpm-lock.yaml)"), - "{out}" - ); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 3); - assert_eq!(launches[0].argv, uv_call(true, "apps/frontend")); - assert!(launches[0].hermetic); - assert_eq!( - launches[1..], - [ - Launch { - argv: npm_check(&harness, "consumer-npm/jobs"), - cwd: harness.workspace.clone(), - env: npm_env(), - hermetic: false, - }, - Launch { - argv: npm_check(&harness, "consumer-npm/web"), - cwd: harness.workspace.clone(), - env: npm_env(), - hermetic: false, - }, - ] - ); - drop(launches); - for rel in [ - "consumer-npm/jobs/pnpm-lock.yaml", - "consumer-npm/web/pnpm-lock.yaml", - ] { - assert_eq!(read_bytes(&harness, rel), PNPM_LOCK.as_bytes(), "{rel}"); - } - assert!( - !harness - .workspace - .join("consumer-npm/web/node_modules") - .exists() - ); - } - - #[test] - fn consumer_npm_apply_runs_one_set_and_leaves_the_others() { - let harness = mixed_harness("npm-apply"); - let before: Vec> = [ - "consumer-npm/jobs/package.json", - "consumer-npm/jobs/pnpm-lock.yaml", - "apps/frontend/uv.lock", - ] - .iter() - .map(|rel| read_bytes(&harness, rel)) - .collect(); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--apply", "web"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("updated web (consumer-npm/web/pnpm-lock.yaml)"), - "{out}" - ); - assert_eq!( - *runner.launches.borrow(), - vec![Launch { - argv: npm_call( - &harness, - &[], - false, - "consumer-npm/web", - &["install", "--lockfile-only"] - ), - cwd: harness.workspace.clone(), - env: npm_env(), - hermetic: false, - }] - ); - let after: Vec> = [ - "consumer-npm/jobs/package.json", - "consumer-npm/jobs/pnpm-lock.yaml", - "apps/frontend/uv.lock", - ] - .iter() - .map(|rel| read_bytes(&harness, rel)) - .collect(); - assert_eq!(before, after); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &["update", "--apply", "web:@types/node", "web:react"], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("updated web:@types/node, web:react (consumer-npm/web/pnpm-lock.yaml)"), - "{out}" - ); - assert_eq!( - *runner.calls.borrow(), - vec![npm_call( - &harness, - &[], - false, - "consumer-npm/web", - &["update", "@types/node", "react", "--lockfile-only"] - )] - ); - } - - #[test] - fn consumer_npm_startup_options_and_offline_check_reach_the_bazel_run() { - let harness = mixed_harness("npm-startup"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &[ - "update", - "--offline", - "--bazel-startup-option=--output_base=/tmp/npm-ob", - "web", - ], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - *runner.calls.borrow(), - vec![npm_call( - &harness, - &["--output_base=/tmp/npm-ob".to_owned()], - true, - "consumer-npm/web", - &[ - "install", - "--lockfile-only", - "--frozen-lockfile", - "--offline" - ] - )] - ); - } - - #[test] - fn consumer_npm_bazel_run_sees_the_held_slot() { - let harness = mixed_harness("npm-slot"); - let scratch = dx_test_scratch::scratch("dx-update-consumer-slot-"); - let gate = - dx_plan::slots::Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), false); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = dx_plan::slots::with_gate(Some(gate), || { - run_with(&["update", "web"], &runner, &harness) - }); - assert_eq!(code, 0, "{out}{err}"); - let marked = (dx_plan::slots::SLOT_ENV_VAR.to_owned(), "1".to_owned()); - let launches = runner.launches.borrow(); - assert!(!launches.is_empty()); - for launch in launches.iter() { - assert!(launch.env.contains(&marked), "{launch:?}"); - } - } - - #[test] - fn consumer_npm_failures_stay_with_their_own_set() { - let harness = mixed_harness("npm-fail"); - let runner = ScriptRunner::new(&[("consumer-npm:web", Some(1))]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("failed to check web: updater exited 1"), - "{err}" - ); - assert!(!out.contains("web lockfile current"), "{out}"); - assert!( - out.contains("jobs lockfile current (consumer-npm/jobs/pnpm-lock.yaml)"), - "{out}" - ); - assert!( - out.contains("deps-frontend lockfile current (apps/frontend/uv.lock)"), - "{out}" - ); - assert_eq!(runner.calls.borrow().len(), 3); - } - - #[test] - fn consumer_npm_refusals_never_launch_pnpm() { - let harness = mixed_harness("npm-refuse"); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with(&["update", "--apply", "--frozen", "web"], &runner, &harness); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--offline", "web"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, _, err) = run_with(&["update", "web:react"], &runner, &harness); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - let (code, _, err) = run_with(&["update", "--apply", "web:React"], &runner, &harness); - assert_eq!(code, 2, "{err}"); - assert!( - err.contains("invalid package \"React\" for set web"), - "{err}" - ); - assert!(runner.calls.borrow().is_empty()); - std::fs::remove_file(harness.workspace.join("consumer-npm/web/pnpm-lock.yaml")) - .expect("drop lock"); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("consumer-npm/web/pnpm-lock.yaml"), "{err}"); - assert!(err.contains("is missing"), "{err}"); - assert_eq!( - *runner.calls.borrow(), - vec![ - uv_call(true, "apps/frontend"), - npm_check(&harness, "consumer-npm/jobs") - ] - ); - } - - #[test] - fn consumer_npm_parent_pnpm_workspace_is_unsupported() { - let harness = mixed_harness("npm-parent"); - harness.write_source("consumer-npm/pnpm-workspace.yaml", "packages: [\"*\"]\n"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--apply", "web"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!( - err.contains( - "consumer-npm/pnpm-workspace.yaml makes pnpm write the parent workspace lock" - ), - "{err}" - ); - assert!(runner.calls.borrow().is_empty()); - } - - const PINNED_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\nruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(name = \"gems\", gemfile = \"//apps/rb:Gemfile\", gemfile_lock = \"//apps/rb:Gemfile.lock\")\n"; - - const PINNED_FILES: [(&str, &str); 2] = [ - ( - "apps/rb/Gemfile", - "source \"https://rubygems.org\"\ngem \"rspec-core\"\n", - ), - ( - "apps/rb/Gemfile.lock", - "GEM\n remote: https://rubygems.org/\n specs:\n rspec-core (3.13.0)\n", - ), - ]; - - fn pinned_harness(name: &str) -> Harness { - let harness = consumer_harness(name, PINNED_MODULE, true); - for (rel, text) in PINNED_FILES { - harness.write_source(rel, text); - } - harness - } - - fn assert_pinned_files_unchanged(harness: &Harness) { - for (rel, text) in PINNED_FILES { - assert_eq!(read_bytes(harness, rel), text.as_bytes(), "{rel}"); - } - } - - #[test] - fn consumer_ruby_set_reports_pinned_and_launches_nothing() { - for (argv, uv) in [ - (vec!["update"], uv_call(true, "apps/frontend")), - ( - vec!["update", "--offline", "--frozen"], - uv_call(true, "apps/frontend"), - ), - (vec!["update", "--apply"], uv_call(false, "apps/frontend")), - ] { - let harness = pinned_harness("pinned"); - let runner = ScriptRunner::new(&[]); - let mut json = argv.clone(); - json.push("--output=json"); - let (code, out, err) = run_with(&json, &runner, &harness); - let launches: Vec> = runner - .launches - .borrow() - .iter() - .map(|launch| launch.argv.clone()) - .collect(); - assert!( - launches.iter().all(|argv| argv[0] == fake_pinned("uv")), - "only uv runs: {launches:?}" - ); - if !argv.contains(&"--offline") { - assert_eq!(code, 0, "{argv:?} {out}{err}"); - assert_eq!(launches, vec![uv.clone()], "{argv:?}"); - } - let events = json_events(&out); - assert!( - events - .iter() - .any(|event| event["code"] == "update_set_pinned" - && event["correlation"] == "update:gems"), - "{argv:?}: {out}" - ); - assert!(!out.contains("gems lockfile current"), "{out}"); - assert!(!out.contains("updated gems"), "{out}"); - assert_pinned_files_unchanged(&harness); - } - let harness = pinned_harness("pinned-text"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("gems pins are manual; nothing to resolve"), - "{out}" - ); - } - - #[test] - fn consumer_pinned_package_selection_is_unsupported_without_fallback() { - for mode in ["--apply", "--output=text"] { - let harness = pinned_harness("pinned-selective"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = - run_with(&["update", mode, "gems:rspec-core"], &runner, &harness); - assert_ne!(code, 0, "{mode}: {out}{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(err.contains("bundle lock"), "{err}"); - assert!(!out.contains("pins are manual"), "{out}"); - assert!(runner.launches.borrow().is_empty()); - assert_pinned_files_unchanged(&harness); - } - } - - #[test] - fn consumer_pinned_set_without_its_lock_declared_is_unsupported() { - let harness = consumer_harness( - "pinned-lock", - "ruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(name = \"gems\", gemfile = \"//apps/rb:Gemfile\", gemfile_lock = \"//apps/rb:gems.locked\")\n", - true, - ); - harness.write_source("apps/rb/Gemfile", PINNED_FILES[0].1); - harness.write_source("apps/rb/gems.locked", PINNED_FILES[1].1); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("apps/rb/Gemfile.lock"), "{err}"); - assert!(!out.contains("pins are manual"), "{out}"); - assert!(runner.launches.borrow().is_empty()); - } - - const CARGO_MANIFEST: &str = - "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n"; - - const CARGO_LOCK: &str = "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.200\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n"; - - const CARGO_FILES: [&str; 6] = [ - "consumer-rs/crates/Cargo.toml", - "consumer-rs/crates/Cargo.lock", - "consumer-rs/crates/cargo-bazel-lock.json", - "consumer-rs/tools/Cargo.toml", - "consumer-rs/tools/Cargo.lock", - "consumer-rs/tools/cargo-bazel-lock.json", - ]; - - const CARGO_MODULE: &str = "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//consumer-rs/crates:Cargo.lock\", lockfile = \"//consumer-rs/crates:cargo-bazel-lock.json\", manifests = [\"//consumer-rs/crates:Cargo.toml\"])\ncrate.from_cargo(name = \"tool_crates\", cargo_lockfile = \"//consumer-rs/tools:Cargo.lock\", lockfile = \"//consumer-rs/tools:cargo-bazel-lock.json\", manifests = [\"//consumer-rs/tools:Cargo.toml\"])\nuse_repo(crate, \"crates\", \"tool_crates\")\n"; - - fn cargo_harness(name: &str) -> Harness { - let harness = consumer_harness(name, &format!("{MIXED_MODULE}{CARGO_MODULE}"), true); - for dir in ["consumer-npm/jobs", "consumer-npm/web"] { - harness.write_source(&format!("{dir}/package.json"), PACKAGE_JSON); - harness.write_source(&format!("{dir}/pnpm-lock.yaml"), PNPM_LOCK); - } - for dir in ["consumer-rs/crates", "consumer-rs/tools"] { - harness.write_source(&format!("{dir}/Cargo.toml"), CARGO_MANIFEST); - harness.write_source(&format!("{dir}/Cargo.lock"), CARGO_LOCK); - harness.write_source(&format!("{dir}/cargo-bazel-lock.json"), "{}\n"); - } - harness - } - - #[allow(clippy::disallowed_methods)] // test helper mirrors the backend argv - fn cargo_call(startup: &[String], offline: bool, repository: &str) -> Vec { - let mut argv = dx_process::startup_argv(startup); - argv.push("fetch".to_owned()); - argv.extend(dx_process::build_verb_options( - "fetch", - &["--config=dx_build_dev".to_owned()], - )); - argv.push("--config=dx_build_dev".to_owned()); - if offline { - argv.push("--repository_disable_download".to_owned()); - } - argv.push(format!("--repo=@{repository}")); - argv - } - - fn cargo_env(repin: &str, repository: &str) -> Vec<(String, String)> { - dx_update::backend::cargo_env(repin, repository) - } - - fn cargo_bytes(harness: &Harness) -> Vec> { - CARGO_FILES - .iter() - .map(|rel| read_bytes(harness, rel)) - .collect() - } - - #[test] - fn consumer_cargo_env_forces_both_repin_triggers() { - assert_eq!( - cargo_env("false", "crates"), - vec![ - ("CARGO_BAZEL_ISOLATED".to_owned(), "true".to_owned()), - ("CARGO_BAZEL_REPIN".to_owned(), "false".to_owned()), - ("CARGO_BAZEL_REPIN_ONLY".to_owned(), "crates".to_owned()), - ("REPIN".to_owned(), "false".to_owned()), - ("CARGO_BAZEL_DIGEST_CHECK".to_owned(), "1".to_owned()), - ] - ); - } - - #[test] - fn consumer_cargo_check_env_checks_the_lock_digest_but_apply_does_not() { - let has = - |env: Vec<(String, String)>| env.iter().any(|(k, _)| k == "CARGO_BAZEL_DIGEST_CHECK"); - assert!(has(cargo_env("false", "crates"))); - assert!(!has(cargo_env("workspace", "crates"))); - assert!(!has(cargo_env("serde", "crates"))); - } - - #[test] - fn consumer_mixed_check_runs_uv_npm_and_cargo_each_their_own_way() { - let harness = cargo_harness("cargo-check"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("crates lockfile current (consumer-rs/crates/Cargo.lock, consumer-rs/crates/cargo-bazel-lock.json)"), - "{out}" - ); - assert!( - out.contains("web lockfile current (consumer-npm/web/pnpm-lock.yaml)"), - "{out}" - ); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 5, "{launches:?}"); - assert_eq!(launches[1].argv, uv_call(true, "apps/frontend")); - assert!(launches[1].hermetic); - assert_eq!( - launches[0], - Launch { - argv: cargo_call(&[], false, "crates"), - cwd: harness.workspace.clone(), - env: cargo_env("false", "crates"), - hermetic: false, - } - ); - assert_eq!(launches[2].argv, npm_check(&harness, "consumer-npm/jobs")); - assert_eq!(launches[2].env, npm_env()); - assert_eq!( - launches[3], - Launch { - argv: cargo_call(&[], false, "tool_crates"), - cwd: harness.workspace.clone(), - env: cargo_env("false", "tool_crates"), - hermetic: false, - } - ); - assert_eq!(launches[4].argv, npm_check(&harness, "consumer-npm/web")); - } - - #[test] - fn consumer_cargo_apply_repins_one_set_and_leaves_the_others() { - let harness = cargo_harness("cargo-apply"); - let others = [ - "consumer-rs/tools/Cargo.lock", - "consumer-rs/tools/cargo-bazel-lock.json", - "consumer-npm/web/pnpm-lock.yaml", - "apps/frontend/uv.lock", - ]; - let before: Vec> = others.iter().map(|rel| read_bytes(&harness, rel)).collect(); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--apply", "crates"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("updated crates (consumer-rs/crates/Cargo.lock, consumer-rs/crates/cargo-bazel-lock.json)"), - "{out}" - ); - assert_eq!( - *runner.launches.borrow(), - vec![Launch { - argv: cargo_call(&[], false, "crates"), - cwd: harness.workspace.clone(), - env: cargo_env("workspace", "crates"), - hermetic: false, - }] - ); - let after: Vec> = others.iter().map(|rel| read_bytes(&harness, rel)).collect(); - assert_eq!(before, after); - } - - #[test] - fn consumer_cargo_apply_package_repins_one_crate() { - let harness = cargo_harness("cargo-apply-package"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--apply", "crates:serde"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("updated crates:serde (consumer-rs/crates/Cargo.lock, consumer-rs/crates/cargo-bazel-lock.json)"), - "{out}" - ); - assert_eq!( - *runner.launches.borrow(), - vec![Launch { - argv: cargo_call(&[], false, "crates"), - cwd: harness.workspace.clone(), - env: cargo_env("serde", "crates"), - hermetic: false, - }] - ); - } - - #[test] - fn consumer_cargo_package_and_full_sets_in_one_run() { - let harness = cargo_harness("cargo-package-and-full"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &["update", "--apply", "crates:serde", "tool_crates"], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 2); - assert_eq!(launches[0].argv, cargo_call(&[], false, "crates")); - assert_eq!(launches[0].env, cargo_env("serde", "crates")); - assert_eq!(launches[1].argv, cargo_call(&[], false, "tool_crates")); - assert_eq!(launches[1].env, cargo_env("workspace", "tool_crates")); - } - - #[test] - fn consumer_cargo_package_failure_stays_with_its_own_set() { - let harness = cargo_harness("cargo-package-fail"); - let runner = ScriptRunner::new(&[("consumer-cargo:crates", Some(1))]); - let (code, out, err) = run_with( - &["update", "--apply", "crates:serde", "tool_crates"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("updater exited 1"), "{err}"); - assert!(out.contains("updated tool_crates"), "{out}"); - assert_eq!(runner.calls.borrow().len(), 2); - } - - #[test] - fn consumer_cargo_failure_stays_with_its_own_set() { - let harness = cargo_harness("cargo-fail"); - let runner = ScriptRunner::new(&[("consumer-cargo:tool_crates", Some(1))]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("failed to check tool_crates: updater exited 1"), - "{err}" - ); - assert!(!out.contains("tool_crates lockfile current"), "{out}"); - assert!(out.contains("crates lockfile current"), "{out}"); - assert!(out.contains("web lockfile current"), "{out}"); - assert_eq!(runner.calls.borrow().len(), 5); - } - - #[test] - fn consumer_cargo_refusals_never_launch_bazel() { - let harness = cargo_harness("cargo-refuse"); - let before = cargo_bytes(&harness); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with( - &["update", "--apply", "--frozen", "crates"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--offline", "crates"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, out, err) = run_with(&["update", "crates:serde"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(err.contains("select the set without a package"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--frozen", "crates:serde"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--offline", "crates:serde"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "crates:serde", "crates:tokio"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(err.contains("one crate per run"), "{err}"); - for name in ["off", "all"] { - let (code, _, err) = run_with( - &["update", "--apply", &format!("crates:{name}")], - &runner, - &harness, - ); - assert_eq!(code, 1, "{name}: {err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(err.contains("repin mode"), "{err}"); - } - assert!(runner.calls.borrow().is_empty()); - assert_eq!(cargo_bytes(&harness), before); - } - - #[test] - fn consumer_cargo_offline_check_disables_downloads() { - let harness = cargo_harness("cargo-offline"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &[ - "update", - "--offline", - "--bazel-startup-option=--output_base=/tmp/cargo-ob", - "crates", - ], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - *runner.calls.borrow(), - vec![cargo_call( - &["--output_base=/tmp/cargo-ob".to_owned()], - true, - "crates" - )] - ); - } - - #[test] - fn consumer_bazel_check_that_writes_fails_and_is_restored() { - let harness = cargo_harness("cargo-guard"); - let before = cargo_bytes(&harness); - let runner = ScriptRunner::new(&[]); - runner.writes.borrow_mut().push(( - harness.workspace.join("consumer-rs/crates/Cargo.lock"), - b"rewritten\n".to_vec(), - )); - let (code, out, err) = run_with(&["update", "crates"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("failed to check crates: check_wrote: consumer-rs/crates/Cargo.lock (restored); declare `lockfile` in crate.from_cargo"), - "{err}" - ); - assert!(!out.contains("crates lockfile current"), "{out}"); - assert_eq!(cargo_bytes(&harness), before); - let runner = ScriptRunner::new(&[]); - runner.writes.borrow_mut().push(( - harness.workspace.join("consumer-npm/web/pnpm-lock.yaml"), - b"rewritten\n".to_vec(), - )); - let (code, out, err) = run_with(&["update", "web"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains( - "failed to check web: check_wrote: consumer-npm/web/pnpm-lock.yaml (restored)" - ), - "{err}" - ); - assert!(!err.contains("crate.from_cargo"), "{err}"); - assert_eq!( - read_bytes(&harness, "consumer-npm/web/pnpm-lock.yaml"), - PNPM_LOCK.as_bytes() - ); - } - - const MAVEN_MODULE: &str = "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"tool_maven\", lock_file = \"//tools/jvm:tool_install.json\")\nuse_repo(maven, \"maven\", \"tool_maven\")\n"; - - const MAVEN_LOCK: &str = "{\"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY\":\"x\",\"__INPUT_ARTIFACTS_HASH\":1,\"__RESOLVED_ARTIFACTS_HASH\":2,\"artifacts\":{}}\n"; - - const MAVEN_FILES: [&str; 3] = [ - "MODULE.bazel", - "third_party/maven_install.json", - "tools/jvm/tool_install.json", - ]; - - fn maven_harness(name: &str) -> Harness { - let harness = consumer_harness(name, &format!("{UV_MODULE}{MAVEN_MODULE}"), true); - harness.write_source("third_party/maven_install.json", MAVEN_LOCK); - harness.write_source("tools/jvm/tool_install.json", MAVEN_LOCK); - harness - } - - #[allow(clippy::disallowed_methods)] // test helper mirrors the backend argv - fn maven_pin_call(repository: &str) -> Vec { - let mut argv = dx_process::startup_argv(&[]); - argv.push("run".to_owned()); - argv.push("--config=dx_build_dev".to_owned()); - argv.push(format!("@{repository}//:pin")); - argv - } - - fn maven_bytes(harness: &Harness) -> Vec> { - MAVEN_FILES - .iter() - .map(|rel| read_bytes(harness, rel)) - .collect() - } - - #[test] - fn consumer_maven_check_fetches_each_hub_without_repin_env() { - let harness = maven_harness("maven-check"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "maven", "tool_maven"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("maven lockfile current (third_party/maven_install.json)"), - "{out}" - ); - let launches = runner.launches.borrow(); - assert_eq!( - *launches, - vec![ - Launch { - argv: cargo_call(&[], false, "maven"), - cwd: harness.workspace.clone(), - env: Vec::new(), - hermetic: false, - }, - Launch { - argv: cargo_call(&[], false, "tool_maven"), - cwd: harness.workspace.clone(), - env: Vec::new(), - hermetic: false, - }, - ] - ); - } - - #[test] - fn consumer_maven_apply_pins_one_set_and_leaves_the_other_lock() { - let harness = maven_harness("maven-apply"); - let before = maven_bytes(&harness); - let runner = ScriptRunner::new(&[]); - runner.writes.borrow_mut().push(( - harness.workspace.join("third_party/maven_install.json"), - b"{\"pinned\":true}\n".to_vec(), - )); - let (code, out, err) = run_with(&["update", "--apply", "maven"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("updated maven"), "{out}"); - assert_eq!( - *runner.launches.borrow(), - vec![Launch { - argv: maven_pin_call("maven"), - cwd: harness.workspace.clone(), - env: vec![("RULES_JVM_EXTERNAL_REPIN".to_owned(), "1".to_owned())], - hermetic: false, - }] - ); - let after = maven_bytes(&harness); - assert_eq!(after[0], before[0]); - assert_ne!(after[1], before[1]); - assert_eq!(after[2], before[2]); - } - - #[test] - fn consumer_maven_refusals_never_launch_bazel() { - let harness = maven_harness("maven-refusals"); - for argv in [ - vec!["update", "--apply", "maven:com.google.guava:guava"], - vec!["update", "--apply", "--offline", "maven"], - vec!["update", "maven:com.google.guava:guava"], - ] { - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&argv, &runner, &harness); - assert_ne!(code, 0, "{argv:?}: {out}{err}"); - assert!(runner.launches.borrow().is_empty(), "{argv:?}"); - } - let runner = ScriptRunner::new(&[]); - let (_, out, err) = run_with( - &["update", "--apply", "maven:com.google.guava:guava"], - &runner, - &harness, - ); - assert!( - format!("{out}{err}").contains("maven pins are exact in MODULE.bazel"), - "{out}{err}" - ); - } - - #[test] - fn consumer_maven_check_needs_the_hub_and_a_signed_lock() { - for (name, rewrite) in [ - ("empty", ("third_party/maven_install.json", "")), - ( - "v1", - ("third_party/maven_install.json", "{\"dependency_tree\":{}}"), - ), - ] { - let harness = maven_harness(&format!("maven-unready-{name}")); - harness.write_source(rewrite.0, rewrite.1); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "maven"], &runner, &harness); - assert_ne!(code, 0, "{name}: {out}{err}"); - assert!(runner.launches.borrow().is_empty(), "{name}"); - assert!(!out.contains("lockfile current"), "{name}: {out}"); - assert!( - format!("{out}{err}").contains("cannot check maven"), - "{name}: {out}{err}" - ); - } - } - - #[test] - fn consumer_maven_check_that_writes_is_restored_and_failed() { - let harness = maven_harness("maven-guard"); - let before = maven_bytes(&harness); - let runner = ScriptRunner::new(&[]); - runner.writes.borrow_mut().push(( - harness.workspace.join("third_party/maven_install.json"), - b"rewritten\n".to_vec(), - )); - let (code, out, err) = run_with(&["update", "maven"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("check_wrote: third_party/maven_install.json (restored)"), - "{err}" - ); - assert_eq!(maven_bytes(&harness), before); - } - - #[test] - fn consumer_maven_failing_pin_fails_only_its_own_set() { - let harness = maven_harness("maven-fail"); - let key = ScriptRunner::key_for(&maven_pin_call("maven")); - let runner = ScriptRunner::new(&[(key.as_str(), Some(1))]); - let (code, out, err) = run_with( - &["update", "--apply", "maven", "tool_maven"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{out}{err}"); - assert!( - format!("{out}{err}").contains("failed to update maven"), - "{out}{err}" - ); - assert!(out.contains("updated tool_maven"), "{out}{err}"); - assert_eq!(runner.launches.borrow().len(), 2); - } - - const NUGET_FILES: [&str; 6] = [ - "MODULE.bazel", - "third_party/dotnet/paket.dependencies", - "third_party/dotnet/paket.lock", - "third_party/dotnet/deps/paket.main.bzl", - "third_party/dotnet_tools/paket.lock", - "third_party/dotnet_tools/deps/paket.tools.bzl", - ]; - - fn nuget_harness(name: &str) -> Harness { - let harness = consumer_harness( - name, - &format!( - "{UV_MODULE}paket = use_extension(\"//third_party/dotnet/deps:paket.main_extension.bzl\", \"main_extension\")\n" - ), - true, - ); - for dir in ["dotnet", "dotnet_tools"] { - harness.write_source( - &format!("third_party/{dir}/paket.dependencies"), - "source https://api.nuget.org/v3/index.json\nnuget Newtonsoft.Json\n", - ); - harness.write_source(&format!("third_party/{dir}/paket.lock"), "NUGET\n"); - } - harness.write_source("third_party/dotnet/deps/paket.main.bzl", "main\n"); - harness.write_source("third_party/dotnet_tools/deps/paket.tools.bzl", "tools\n"); - harness - } - - fn nuget_bytes(harness: &Harness) -> Vec> { - NUGET_FILES - .iter() - .map(|rel| read_bytes(harness, rel)) - .collect() - } - - fn stage(runner: &ScriptRunner, files: &[(&str, &str)]) { - runner.staged_outputs.borrow_mut().extend( - files - .iter() - .map(|(name, text)| ((*name).to_owned(), text.as_bytes().to_vec())), - ); - } - - /// The `--output-folder` the single recorded launch ran with. - fn scratch_out(runner: &ScriptRunner) -> std::path::PathBuf { - let launches = runner.launches.borrow(); - let argv = &launches[0].argv; - let at = argv - .iter() - .position(|arg| arg == "--output-folder") - .expect("folder"); - std::path::PathBuf::from(&argv[at + 1]) - } - - #[test] - fn consumer_nuget_check_without_drift_is_current_and_cleans_scratch() { - let harness = nuget_harness("nuget-current"); - let before = nuget_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage( - &runner, - &[ - ("paket.main.bzl", "main\n"), - ("paket.main_extension.bzl", "ext\n"), - ], - ); - let (code, out, err) = run_with(&["update", "nuget"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("nuget lockfile current"), "{out}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1); - let argv = &launches[0].argv; - #[allow(clippy::disallowed_methods)] - let mut head = dx_process::startup_argv(&[]); - head.extend(strings_of(&[ - "run", - "--config=dx_build_dev", - "@rules_dx//dotnet:paket2bazel", - "--", - "--dependencies-file", - ])); - assert_eq!(argv[..head.len()], head[..], "{argv:?}"); - assert_eq!(argv.len(), head.len() + 3); - assert_eq!(argv[head.len() + 1], "--output-folder"); - assert_eq!(launches[0].cwd, harness.workspace); - assert!(launches[0].env.is_empty()); - assert!(!launches[0].hermetic); - let deps = std::path::Path::new(&argv[head.len()]); - assert!(!deps.starts_with(&harness.workspace), "{deps:?}"); - assert!(!deps.exists(), "scratch must be removed: {deps:?}"); - assert_eq!(nuget_bytes(&harness), before); - } - - fn strings_of(words: &[&str]) -> Vec { - words.iter().map(|word| (*word).to_owned()).collect() - } - - #[test] - fn consumer_nuget_check_with_drift_fails_listing_files_and_writes_nothing() { - let harness = nuget_harness("nuget-drift"); - let before = nuget_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage( - &runner, - &[ - ("paket.main.bzl", "new\n"), - ("paket.main_extension.bzl", "ext\n"), - ], - ); - let (code, out, err) = run_with(&["update", "nuget"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("stale generated locks: third_party/dotnet/deps/paket.main.bzl;"), - "{err}" - ); - assert!(!err.contains("paket.main_extension.bzl"), "{err}"); - assert!(!out.contains("lockfile current"), "{out}"); - assert_eq!(nuget_bytes(&harness), before); - } - - #[test] - fn consumer_nuget_apply_writes_only_differing_declared_files() { - let harness = nuget_harness("nuget-apply"); - let before = nuget_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage( - &runner, - &[ - ("paket.main.bzl", "new\n"), - ("paket.main_extension.bzl", "ext\n"), - ], - ); - let (code, out, err) = run_with(&["update", "--apply", "nuget"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("updated nuget"), "{out}"); - let after = nuget_bytes(&harness); - for (index, rel) in NUGET_FILES.iter().enumerate() { - if *rel == "third_party/dotnet/deps/paket.main.bzl" { - assert_eq!(after[index], b"new\n", "{rel}"); - } else { - assert_eq!(after[index], before[index], "{rel}"); - } - } - assert!( - !harness - .workspace - .join("third_party/dotnet/deps/paket.main_extension.bzl") - .exists() - ); - assert!(!scratch_out(&runner).exists()); - } - - const HEX_MODULE: &str = "elixir = use_extension(\"//elixir/toolchain:extensions.bzl\", \"elixir\")\nelixir.lock(name = \"deps\", lock = \"//third_party/elixir:mix.lock\")\n"; - - fn hex_harness(name: &str) -> Harness { - let harness = consumer_harness(name, HEX_MODULE, false); - harness.write_source("third_party/elixir/mix.exs", "defmodule App do\nend\n"); - harness.write_source("third_party/elixir/mix.lock", "%{}\n"); - harness - } - - #[test] - fn consumer_hex_check_reports_drift_and_apply_writes_only_mix_lock() { - let harness = hex_harness("hex-apply"); - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("mix.lock", "%{\"jason\" => 1}\n")]); - let (code, out, err) = run_with(&["update", "deps"], &runner, &harness); - assert_ne!(code, 0, "{out}{err}"); - assert_eq!( - read_bytes(&harness, "third_party/elixir/mix.lock"), - b"%{}\n" - ); - - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("mix.lock", "%{\"jason\" => 1}\n")]); - let (code, out, err) = run_with(&["update", "--apply", "deps"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read_bytes(&harness, "third_party/elixir/mix.lock"), - b"%{\"jason\" => 1}\n" - ); - assert_eq!( - read_bytes(&harness, "third_party/elixir/mix.exs"), - b"defmodule App do\nend\n" - ); - let launches = runner.launches.borrow(); - assert!(launches[0].argv.iter().any(|arg| arg == "deps.update")); - } - - #[test] - fn consumer_mixed_mix_and_rebar_run_still_checks_the_mix_set() { - let module = format!( - "{HEX_MODULE}erlang = use_extension(\"//erlang/toolchain:repos.bzl\", \"erlang\")\nerlang.rebar(name = \"erl\", lock = \"//third_party/erlang:rebar.lock\")\n" - ); - let harness = consumer_harness("hex-mixed", &module, false); - harness.write_source("third_party/elixir/mix.exs", "defmodule App do\nend\n"); - harness.write_source("third_party/elixir/mix.lock", "%{}\n"); - harness.write_source("third_party/erlang/rebar.config", "{deps, []}.\n"); - harness.write_source("third_party/erlang/rebar.lock", "[].\n"); - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("mix.lock", "%{\"jason\" => 1}\n")]); - let (code, out, err) = run_with(&["update"], &runner, &harness); - assert_ne!(code, 0, "{out}{err}"); - let launches = runner.launches.borrow(); - assert!(launches[0].argv.iter().any(|arg| arg == "deps.get")); - assert_eq!( - read_bytes(&harness, "third_party/erlang/rebar.lock"), - b"[].\n" - ); - } - - const OPAM_MODULE: &str = "ocaml = use_extension(\"//ocaml/toolchain:extensions.bzl\", \"ocaml\")\nocaml.lock(name = \"ocaml_app\", lock = \"//ocaml/app:ocaml_app.opam.locked\")\n"; - - const OPAM_FILE: &str = "opam-version: \"2.0\"\ndepends: [\n \"astring\" {>= \"0.8.5\"}\n \"ocaml\" {>= \"5.5\"}\n]\n"; - - fn opam_harness(name: &str) -> Harness { - let harness = consumer_harness(name, OPAM_MODULE, false); - harness.write_source("ocaml/app/ocaml_app.opam", OPAM_FILE); - harness.write_source( - "ocaml/app/ocaml_app.opam.locked", - "opam-version: \"2.0\"\ndepends: [\n \"astring\" {= \"0.8.5\"}\n]\n", - ); - harness - } - - #[test] - fn consumer_opam_edits_the_constraint_and_runs_upgrade_then_lock() { - let harness = opam_harness("opam-edit"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &["update", "--apply", "ocaml_app:astring", "0.9.0"], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read_bytes(&harness, "ocaml/app/ocaml_app.opam"), - OPAM_FILE.replace("0.8.5", "0.9.0").as_bytes() - ); - let launches = runner.launches.borrow(); - assert!( - launches - .iter() - .any(|run| run.argv.iter().any(|a| a == "upgrade")) - ); - assert!( - launches - .iter() - .any(|run| run.argv.iter().any(|a| a == "lock")) - ); - } - - #[test] - fn consumer_opam_refuses_an_undeclared_package_and_writes_nothing() { - let harness = opam_harness("opam-undeclared"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &["update", "--apply", "ocaml_app:lwt", "5.7.0"], - &runner, - &harness, - ); - assert_ne!(code, 0, "{out}{err}"); - assert!(runner.launches.borrow().is_empty()); - assert_eq!( - read_bytes(&harness, "ocaml/app/ocaml_app.opam"), - OPAM_FILE.as_bytes() - ); - } - - #[test] - fn consumer_hex_offline_needs_network_and_launches_nothing() { - let harness = hex_harness("hex-offline"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--offline", "deps"], &runner, &harness); - assert_ne!(code, 0, "{out}{err}"); - assert!(runner.launches.borrow().is_empty()); - } - - #[test] - fn consumer_nuget_undeclared_or_missing_output_fails_and_writes_nothing() { - for (name, files, want) in [ - ( - "undeclared", - vec![ - ("paket.main.bzl", "new\n"), - ("paket.main_extension.bzl", "ext2\n"), - ("paket.extra.bzl", "x\n"), - ], - "not declared in locks: paket.extra.bzl", - ), - ( - "missing", - vec![("paket.main_extension.bzl", "ext\n")], - "did not generate declared locks: third_party/dotnet/deps/paket.main.bzl", - ), - ] { - let harness = nuget_harness(&format!("nuget-{name}")); - let before = nuget_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage(&runner, &files); - let (code, out, err) = run_with(&["update", "--apply", "nuget"], &runner, &harness); - assert_eq!(code, 1, "{name}: {out}{err}"); - assert!(err.contains(want), "{name}: {err}"); - assert_eq!(nuget_bytes(&harness), before, "{name}"); - } - } - - #[test] - fn consumer_nuget_failing_run_writes_nothing() { - let harness = nuget_harness("nuget-fail"); - let before = nuget_bytes(&harness); - let key = ScriptRunner::key_for(&strings_of(&["paket2bazel"])); - let runner = ScriptRunner::new(&[(key.as_str(), Some(1))]); - stage( - &runner, - &[ - ("paket.main.bzl", "new\n"), - ("paket.main_extension.bzl", "ext\n"), - ], - ); - let (code, out, err) = run_with(&["update", "--apply", "nuget"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - format!("{out}{err}").contains("failed to update nuget"), - "{out}{err}" - ); - assert_eq!(nuget_bytes(&harness), before); - } - - #[test] - fn consumer_nuget_offline_is_required_and_frozen_apply_runs() { - let harness = nuget_harness("nuget-offline"); - for argv in [ - vec!["update", "--offline", "nuget"], - vec!["update", "--apply", "--offline", "nuget"], - vec!["update", "--apply", "nuget:Newtonsoft.Json"], - ] { - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&argv, &runner, &harness); - assert_ne!(code, 0, "{argv:?}: {out}{err}"); - assert!(runner.launches.borrow().is_empty(), "{argv:?}"); - assert!( - !format!("{out}{err}").contains("lockfile current"), - "{argv:?}" - ); - } - let runner = ScriptRunner::new(&[]); - let (_, out, err) = run_with(&["update", "--offline", "nuget"], &runner, &harness); - assert!( - format!("{out}{err}").contains("offline_required"), - "{out}{err}" - ); - let runner = ScriptRunner::new(&[]); - stage( - &runner, - &[ - ("paket.main.bzl", "main\n"), - ("paket.main_extension.bzl", "ext\n"), - ], - ); - let (code, out, err) = run_with( - &["update", "--apply", "--frozen", "nuget"], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(runner.launches.borrow().len(), 1); - } - - #[test] - fn consumer_nuget_layout_refusal_never_launches() { - let harness = nuget_harness("nuget-layout"); - harness.write_source( - "third_party/dotnet/paket.dependencies", - "source ../local-feed\n", - ); - for mode in ["--output=text", "--apply"] { - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", mode, "nuget"], &runner, &harness); - assert_ne!(code, 0, "{out}{err}"); - assert!(runner.launches.borrow().is_empty()); - assert!(format!("{out}{err}").contains("not http(s)"), "{out}{err}"); - } - } - - const GO_FILES: [&str; 3] = [ - "MODULE.bazel", - "third_party/go/go.mod", - "third_party/go/go.sum", - ]; - - const GO_MOD: &str = - "module example.com/m\n\ngo 1.22\n\nrequire github.com/google/go-cmp v0.7.0\n"; - - fn go_harness(name: &str) -> Harness { - let harness = consumer_harness( - name, - &format!( - "{UV_MODULE}bazel_dep(name = \"rules_go\", version = \"0.50.1\")\ngo_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//third_party/go:go.mod\")\n" - ), - true, - ); - harness.write_source("third_party/go/go.mod", GO_MOD); - harness.write_source("third_party/go/go.sum", "old\n"); - harness - } - - fn go_bytes(harness: &Harness) -> Vec> { - GO_FILES - .iter() - .map(|rel| read_bytes(harness, rel)) - .collect() - } - - /// The `-C` directory the single recorded launch ran in. - fn go_scratch_out(runner: &ScriptRunner) -> std::path::PathBuf { - let launches = runner.launches.borrow(); - let argv = &launches[0].argv; - let at = argv.iter().position(|arg| arg == "-C").expect("-C"); - std::path::PathBuf::from(&argv[at + 1]) - } - - #[test] - fn consumer_go_check_runs_go_in_scratch_with_forced_env_and_cleans_up() { - let harness = go_harness("go-current"); - let before = go_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("go.sum", "old\n"), ("go.mod", GO_MOD)]); - let (code, out, err) = run_with(&["update", "go"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("go lockfile current"), "{out}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1); - let argv = &launches[0].argv; - #[allow(clippy::disallowed_methods)] - let mut head = dx_process::startup_argv(&[]); - head.extend(strings_of(&[ - "run", - "--config=dx_build_dev", - "@rules_go//go", - "--", - "-C", - ])); - assert_eq!(argv[..head.len()], head[..], "{argv:?}"); - assert_eq!( - argv[head.len() + 1..], - strings_of(&["mod", "download", "all"])[..], - "{argv:?}" - ); - let out_dir = std::path::PathBuf::from(&argv[head.len()]); - let scratch = out_dir.parent().expect("scratch").to_path_buf(); - assert!(out_dir.ends_with("out")); - assert!(!scratch.starts_with(&harness.workspace)); - assert_eq!(launches[0].cwd, harness.workspace); - assert!(!launches[0].hermetic); - let env: std::collections::BTreeMap<&str, &str> = launches[0] - .env - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())) - .collect(); - assert_eq!(env["GOENV"], "off"); - assert_eq!(env["GOWORK"], "off"); - assert_eq!(env["GOTOOLCHAIN"], "local"); - assert_eq!(env["GOFLAGS"], "-modcacherw"); - for key in ["GOMODCACHE", "GOPATH", "GOCACHE"] { - let path = std::path::Path::new(env[key]); - assert!( - path.starts_with(&scratch) && !path.starts_with(&out_dir), - "{key}" - ); - } - assert!(!scratch.exists(), "scratch must be removed: {scratch:?}"); - assert_eq!(go_bytes(&harness), before); - } - - #[test] - fn consumer_go_check_with_drift_fails_and_writes_nothing() { - let harness = go_harness("go-drift"); - let before = go_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("go.sum", "new\n"), ("go.mod", GO_MOD)]); - let (code, out, err) = run_with(&["update", "go"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("stale generated locks: third_party/go/go.sum;"), - "{err}" - ); - assert!(!out.contains("lockfile current"), "{out}"); - assert_eq!(go_bytes(&harness), before); - } - - #[test] - fn consumer_go_apply_writes_only_go_sum() { - let harness = go_harness("go-apply"); - let before = go_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("go.sum", "new\n"), ("go.mod", GO_MOD)]); - let (code, out, err) = run_with(&["update", "--apply", "go"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("updated go"), "{out}"); - let after = go_bytes(&harness); - assert_eq!(after[0], before[0]); - assert_eq!(after[1], before[1]); - assert_eq!(after[2], b"new\n"); - assert!(!go_scratch_out(&runner).exists()); - } - - #[test] - fn consumer_go_changed_go_mod_fails_and_writes_nothing() { - { - let (name, files, want) = ( - "mod", - vec![("go.sum", "new\n"), ("go.mod", "module changed\n")], - "go changed go.mod", - ); - let harness = go_harness(&format!("go-{name}")); - let before = go_bytes(&harness); - let runner = ScriptRunner::new(&[]); - stage(&runner, &files); - let (code, out, err) = run_with(&["update", "--apply", "go"], &runner, &harness); - assert_eq!(code, 1, "{name}: {out}{err}"); - assert!(err.contains(want), "{name}: {err}"); - assert_eq!(go_bytes(&harness), before, "{name}"); - } - } - - #[test] - fn consumer_go_failing_run_fails_only_its_set() { - let harness = go_harness("go-fail"); - let before = go_bytes(&harness); - let key = ScriptRunner::key_for(&strings_of(&["@rules_go//go"])); - let runner = ScriptRunner::new(&[(key.as_str(), Some(1))]); - stage(&runner, &[("go.sum", "new\n"), ("go.mod", GO_MOD)]); - let (code, out, err) = run_with(&["update", "--apply", "go"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - format!("{out}{err}").contains("failed to update go"), - "{out}{err}" - ); - assert_eq!(go_bytes(&harness), before); - } - - #[test] - fn consumer_go_offline_and_frozen_apply_never_launch() { - let harness = go_harness("go-offline"); - for (argv, want) in [ - (vec!["update", "--offline", "go"], "offline_required"), - ( - vec!["update", "--apply", "--offline", "go"], - "offline_required", - ), - (vec!["update", "--apply", "--frozen", "go"], "frozen_locked"), - ( - vec!["update", "--apply", "go:github.com/google/go-cmp"], - "whole module graph", - ), - ] { - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&argv, &runner, &harness); - assert_ne!(code, 0, "{argv:?}: {out}{err}"); - assert!(runner.launches.borrow().is_empty(), "{argv:?}"); - let text = format!("{out}{err}"); - assert!(text.contains(want), "{argv:?}: {text}"); - assert!(!text.contains("lockfile current"), "{argv:?}"); - } - // A frozen check only compares, so it still runs. - let runner = ScriptRunner::new(&[]); - stage(&runner, &[("go.sum", "old\n"), ("go.mod", GO_MOD)]); - let (code, out, err) = run_with(&["update", "--frozen", "go"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(runner.launches.borrow().len(), 1); - } - - #[test] - fn consumer_go_layout_refusal_never_launches() { - let harness = go_harness("go-layout"); - harness.write_source( - "third_party/go/go.mod", - &format!("{GO_MOD}\nreplace example.com/x => ../x\n"), - ); - for mode in ["--output=text", "--apply"] { - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", mode, "go"], &runner, &harness); - assert_ne!(code, 0, "{out}{err}"); - assert!(runner.launches.borrow().is_empty()); - assert!(format!("{out}{err}").contains("local path"), "{out}{err}"); - } - } - - #[cfg(unix)] - #[test] - fn scratch_removal_survives_a_read_only_module_cache() { - use std::os::unix::fs::PermissionsExt; - let scratch = crate::exec::staged::Scratch::create("readonly").expect("scratch"); - let dir = scratch.0.join("gomodcache/pkg/mod"); - std::fs::create_dir_all(&dir).expect("dir"); - std::fs::write(dir.join("f.go"), "package f\n").expect("file"); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).expect("chmod"); - let path = scratch.0.clone(); - drop(scratch); - assert!(!path.exists(), "{path:?}"); - } - - #[cfg(unix)] - #[test] - fn consumer_check_with_a_lock_linking_outside_fails_before_launch() { - for (argv, lock) in [ - (["update", "crates"], "consumer-rs/crates/Cargo.lock"), - (["update", "web"], "consumer-npm/web/pnpm-lock.yaml"), - ] { - let harness = cargo_harness("link-out"); - let outside = harness - .workspace - .parent() - .expect("parent") - .join("outside-lock.txt"); - std::fs::write(&outside, b"outside\n").expect("outside"); - let at = harness.workspace.join(lock); - std::fs::remove_file(&at).expect("remove lock"); - std::os::unix::fs::symlink(&outside, &at).expect("link"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&argv, &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("leaves the workspace"), "{err}"); - assert!(runner.launches.borrow().is_empty()); - assert_eq!(std::fs::read(&outside).expect("outside"), b"outside\n"); - } - } - - #[cfg(unix)] - #[test] - fn consumer_check_that_writes_through_an_inside_link_is_restored() { - let harness = cargo_harness("link-in"); - let at = harness.workspace.join("consumer-rs/crates/Cargo.lock"); - let real = harness.workspace.join("consumer-rs/crates/real.lock"); - std::fs::rename(&at, &real).expect("rename"); - std::os::unix::fs::symlink(&real, &at).expect("link"); - let before = std::fs::read(&real).expect("real"); - let runner = ScriptRunner::new(&[]); - runner - .writes - .borrow_mut() - .push((at.clone(), b"rewritten\n".to_vec())); - let (code, out, err) = run_with(&["update", "crates"], &runner, &harness); - assert_eq!(code, 1, "{out}{err}"); - assert!( - err.contains("check_wrote: consumer-rs/crates/Cargo.lock (restored)"), - "{err}" - ); - assert!( - std::fs::symlink_metadata(&at) - .expect("meta") - .file_type() - .is_symlink() - ); - assert_eq!(std::fs::read(&real).expect("real"), before); - } - - #[test] - fn consumer_cargo_wrong_manifest_name_exits_two() { - let harness = consumer_harness( - "cargo-manifest", - "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//rs:Cargo.lock\", manifests = [\"//rs:cargo.toml\"])\n", - true, - ); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with(&["update"], &runner, &harness); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("must be named Cargo.toml"), "{err}"); - assert!(runner.calls.borrow().is_empty()); - } - - const SWIFT_MODULE: &str = "swift_deps = use_extension(\"@rules_swift_package_manager//:extensions.bzl\", \"swift_deps\")\nswift_deps.from_package(resolved = \"//apps/swift:Package.resolved\", swift = \"//apps/swift:Package.swift\")\n"; - const SWIFT_MANIFEST: &str = "apps/swift/Package.swift"; - - fn swift_harness(name: &str) -> Harness { - let harness = consumer_harness(name, SWIFT_MODULE, true); - harness.write_source( - SWIFT_MANIFEST, - "import PackageDescription\nlet package = Package(name: \"app\", dependencies: [\n .package(url: \"https://github.com/apple/swift-argument-parser\", exact: \"1.8.2\"),\n .package(url: \"https://github.com/apple/swift-log.git\", branch: \"main\"),\n], targets: [])\n", - ); - harness.write_source( - "apps/swift/Package.resolved", - "{\"pins\":[],\"version\":3}\n", - ); - harness - } - - #[test] - fn consumer_swiftpm_apply_edits_the_manifest_then_updates_the_pin() { - let harness = swift_harness("swift-apply"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &[ - "update", - "--apply", - "swiftpm:swift-argument-parser", - "1.9.0", - ], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - let manifest = String::from_utf8(read_bytes(&harness, SWIFT_MANIFEST)).expect("utf8"); - assert!(manifest.contains("exact: \"1.9.0\""), "{manifest}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1, "{launches:?}"); - let argv = &launches[0].argv; - assert_eq!( - argv[argv.len() - 4..], - strings_of(&["--", "package", "update", "swift-argument-parser"])[..], - "{argv:?}" - ); - } - - #[test] - fn consumer_swiftpm_refusals_launch_nothing_and_keep_the_manifest() { - let harness = swift_harness("swift-refuse"); - let before = read_bytes(&harness, SWIFT_MANIFEST); - let runner = ScriptRunner::new(&[]); - for package in ["swift-log", "missing"] { - let (code, _, err) = run_with( - &["update", "--apply", &format!("swiftpm:{package}"), "1.0.0"], - &runner, - &harness, - ); - assert_ne!(code, 0, "{package}: {err}"); - } - let (code, _, err) = run_with( - &[ - "update", - "--apply", - "--frozen", - "swiftpm:swift-argument-parser", - "1.9.0", - ], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - assert_eq!(read_bytes(&harness, SWIFT_MANIFEST), before); - assert!(runner.launches.borrow().is_empty()); - } - - const CPAN_MODULE: &str = "cpan = use_extension(\"@rules_perl//perl/cpan:extensions.bzl\", \"cpan\")\ncpan.install(name = \"cpan_deps\", lock = \"//perl/third_party:cpanfile.snapshot.lock.json\")\n"; - const CPAN_FILES: [&str; 3] = [ - "perl/third_party/cpanfile", - "perl/third_party/cpanfile.snapshot", - "perl/third_party/cpanfile.snapshot.lock.json", - ]; - - fn cpan_harness(name: &str) -> Harness { - let harness = consumer_harness(name, CPAN_MODULE, true); - harness.write_source( - "perl/third_party/cpanfile", - "requires 'Text::CSV', '== 2.06';\n", - ); - harness.write_source( - "perl/third_party/cpanfile.snapshot", - "# carton snapshot format: version 1.0\nDISTRIBUTIONS\n Text-CSV-2.06\n pathname: I/IS/ISHIGAKI/Text-CSV-2.06.tar.gz\n", - ); - harness.write_source( - "perl/third_party/cpanfile.snapshot.lock.json", - "{\"Text-CSV\":{\"strip_prefix\":\"Text-CSV-2.06\"}}\n", - ); - harness.write_source( - "perl/third_party/BUILD.bazel", - "cpan_compiler(name = \"compiler\", cpanfile = \"cpanfile\", lockfile = \"cpanfile.snapshot.lock.json\")\n", - ); - harness - } - - fn cpan_bytes(harness: &Harness) -> Vec> { - CPAN_FILES - .iter() - .map(|rel| read_bytes(harness, rel)) - .collect() - } - - fn carton_tail(argv: &[String]) -> &[String] { - let at = argv - .iter() - .position(|arg| arg == "@rules_dx//perl/tools:carton") - .expect("carton target"); - &argv[at..] - } - - #[test] - fn consumer_cpan_check_installs_the_snapshot_in_deployment_mode() { - let harness = cpan_harness("cpan-check"); - let before = cpan_bytes(&harness); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "cpan_deps"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1, "{launches:?}"); - let argv = &launches[0].argv; - assert!( - argv.iter() - .any(|arg| arg.starts_with("--run_under=cd '") && arg.contains("perl/third_party")), - "{argv:?}" - ); - assert_eq!( - carton_tail(argv), - &strings_of(&[ - "@rules_dx//perl/tools:carton", - "--", - "install", - "--deployment" - ])[..], - "{argv:?}" - ); - assert_eq!(launches[0].env[0].0, "PERL_CARTON_PATH"); - assert_eq!(cpan_bytes(&harness), before); - } - - #[test] - fn consumer_cpan_apply_module_runs_carton_then_the_compiler() { - let harness = cpan_harness("cpan-apply"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &["update", "--apply", "cpan_deps:Text::CSV"], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 2, "{launches:?}"); - assert_eq!( - carton_tail(&launches[0].argv), - &strings_of(&["@rules_dx//perl/tools:carton", "--", "update", "Text::CSV"])[..] - ); - assert_eq!( - launches[1].argv[launches[1].argv.len() - 3..], - strings_of(&["//perl/third_party:compiler", "--", "--incremental"])[..] - ); - } - - #[test] - fn consumer_cpan_refusals_launch_nothing() { - let harness = cpan_harness("cpan-refuse"); - let before = cpan_bytes(&harness); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with(&["update", "--offline", "cpan_deps"], &runner, &harness); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--frozen", "cpan_deps:Text::CSV"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--offline", "cpan_deps:Text::CSV"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, _, err) = run_with(&["update", "cpan_deps:Text::CSV"], &runner, &harness); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_set_unsupported"), "{err}"); - assert!(runner.launches.borrow().is_empty()); - assert_eq!(cpan_bytes(&harness), before); - } - - const CABAL_MODULE: &str = "haskell = use_extension(\"//haskell/toolchain:extensions.bzl\", \"haskell\")\nhaskell.lock(name = \"hackage\", lock = \"//hs:haskell_deps.json\")\n"; - const CABAL_FILES: [&str; 4] = [ - "hs/cabal.project", - "hs/cabal.project.freeze", - "hs/haskell_deps.json", - "hs/app.cabal", - ]; - - fn cabal_harness(name: &str) -> Harness { - let harness = consumer_harness(name, CABAL_MODULE, true); - harness.write_source("hs/cabal.project", "packages: .\n"); - harness.write_source( - "hs/cabal.project.freeze", - "constraints: any.base ==4.20.2.0,\n any.safe ==0.3.21\nindex-state: hackage.haskell.org 2026-09-30T23:43:34Z\n", - ); - harness.write_source( - "hs/haskell_deps.json", - "{\"packages\":[{\"name\":\"base\",\"version\":\"4.20.2.0\"},{\"name\":\"safe\",\"version\":\"0.3.21\"}]}\n", - ); - harness.write_source( - "hs/app.cabal", - "cabal-version: 3.0\nname: app\nversion: 0.1.0.0\nbuild-type: Simple\n\nlibrary\n build-depends: base, safe ==0.3.21\n", - ); - harness.write_source( - "hs/BUILD.bazel", - "haskell_deps_resolve(name = \"resolve\")\nhaskell_deps_resolve_test(name = \"resolve_test\")\n", - ); - harness - } - - fn cabal_bytes(harness: &Harness) -> Vec> { - CABAL_FILES - .iter() - .map(|rel| read_bytes(harness, rel)) - .collect() - } - - #[test] - fn consumer_cabal_check_runs_the_resolve_test() { - let harness = cabal_harness("cabal-check"); - let before = cabal_bytes(&harness); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "hackage"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1, "{launches:?}"); - assert_eq!( - launches[0].argv.last().map(String::as_str), - Some("//hs:resolve_test") - ); - assert_eq!(cabal_bytes(&harness), before); - } - - #[test] - fn consumer_cabal_apply_runs_the_resolve_target() { - let harness = cabal_harness("cabal-apply"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with(&["update", "--apply", "hackage"], &runner, &harness); - assert_eq!(code, 0, "{out}{err}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1, "{launches:?}"); - assert_eq!( - launches[0].argv.last().map(String::as_str), - Some("//hs:resolve") - ); - } - - #[test] - fn consumer_cabal_apply_package_rewrites_the_bound_then_resolves() { - let harness = cabal_harness("cabal-bound"); - let runner = ScriptRunner::new(&[]); - let (code, out, err) = run_with( - &["update", "--apply", "hackage:safe", "0.3.22"], - &runner, - &harness, - ); - assert_eq!(code, 0, "{out}{err}"); - let cabal = String::from_utf8(read_bytes(&harness, "hs/app.cabal")).expect("utf8"); - assert!(cabal.contains("safe ==0.3.22"), "{cabal}"); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1, "{launches:?}"); - assert_eq!( - launches[0].argv.last().map(String::as_str), - Some("//hs:resolve") - ); - } - - #[test] - fn consumer_cabal_refusals_launch_nothing() { - let harness = cabal_harness("cabal-refuse"); - let before = cabal_bytes(&harness); - let runner = ScriptRunner::new(&[]); - let (code, _, err) = run_with(&["update", "--offline", "hackage"], &runner, &harness); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "--frozen", "hackage"], - &runner, - &harness, - ); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - let (code, _, err) = run_with( - &["update", "--apply", "hackage:missing", "1.0"], - &runner, - &harness, - ); - assert_ne!(code, 0, "{err}"); - assert!(runner.launches.borrow().is_empty()); - assert_eq!(cabal_bytes(&harness), before); - } -} +#[path = "update_consumer_tests.rs"] +mod tests; diff --git a/cli/cli/src/exec/update_consumer_tests.rs b/cli/cli/src/exec/update_consumer_tests.rs new file mode 100644 index 0000000000..587a7bddab --- /dev/null +++ b/cli/cli/src/exec/update_consumer_tests.rs @@ -0,0 +1,2437 @@ +#[test] +fn stop_of_reports_the_holder_once_then_the_signal() { + let (mut err, mut events) = (Vec::new(), Vec::new()); + let mut gate = super::SlotGate { + err: &mut err, + events: &mut events, + phase: dx_plan::slots::Phase::new(dx_plan::args::Command::Update), + busy: Some("pid 7".to_owned()), + }; + assert_eq!( + super::stop_of(&mut gate, 1), + dx_plan::slots::Stop::Busy("pid 7".to_owned()) + ); + assert_eq!( + super::stop_of(&mut gate, 130), + dx_plan::slots::Stop::Signal(130) + ); +} + +use super::super::super::test_support::Harness; +use super::super::super::test_support::{event, event_kinds, json_events}; +use super::super::update_live::{Launch, ScriptRunner, fake_pinned}; + +fn uv_call(check: bool, dir: &str) -> Vec { + let mut argv = vec![fake_pinned("uv"), "lock".to_owned()]; + if check { + argv.push("--check".to_owned()); + } + for word in [ + "--directory", + dir, + "--no-config", + "--no-cache", + "--no-python-downloads", + "--python", + ] { + argv.push(word.to_owned()); + } + argv.push(fake_pinned("python3")); + argv +} + +const UV_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\nuv.project(hub_name = \"deps\", pyproject = \"//services/worker:pyproject.toml\", lock = \"//services/worker:uv.lock\")\n"; + +const PYPROJECT: &str = r#" +[project] +name = "project" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = ["anyio>=4"] +"#; + +const UV_LOCK: &str = r#" +version = 1 +requires-python = ">=3.9" + +[[package]] +name = "anyio" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +"#; + +fn consumer_harness(name: &str, module: &str, with_locks: bool) -> Harness { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::SeqCst); + let harness = Harness::new(&format!("update-consumer-{name}-{id}")); + harness.write_source("MODULE.bazel", module); + harness.write_source("apps/frontend/pyproject.toml", PYPROJECT); + harness.write_source("services/worker/pyproject.toml", PYPROJECT); + if with_locks { + harness.write_source("apps/frontend/uv.lock", UV_LOCK); + harness.write_source("services/worker/uv.lock", UV_LOCK); + } + harness +} + +fn run_with(argv: &[&str], runner: &ScriptRunner, harness: &Harness) -> (i32, String, String) { + let words: Vec = argv.iter().map(|word| (*word).to_owned()).collect(); + let invocation = dx_plan::args::parse(&words).expect("parse"); + harness.execute_with(&invocation, runner) +} + +fn run_consumer(argv: &[&str], runner: &ScriptRunner) -> (i32, String, String, Harness) { + let harness = consumer_harness("sets", UV_MODULE, true); + let (code, out, err) = run_with(argv, runner, &harness); + (code, out, err, harness) +} + +#[test] +fn consumer_bare_check_runs_configured_sets_and_no_phantoms() { + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update"], &runner); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("Running update --check for deps-frontend, deps-worker"), + "{out}" + ); + assert!( + out.contains("deps-frontend lockfile current (apps/frontend/uv.lock)"), + "{out}" + ); + assert!( + out.contains("deps-worker lockfile current (services/worker/uv.lock)"), + "{out}" + ); + for phantom in [ + "cargo", + "pnpm", + "maven", + "nuget", + "examples/", + "third_party/", + "quality/tools", + ] { + assert!(!out.contains(phantom), "phantom {phantom} in {out}"); + } + assert_eq!(err, "", "{err}"); + assert_eq!( + *runner.calls.borrow(), + vec![ + uv_call(true, "apps/frontend"), + uv_call(true, "services/worker") + ] + ); + assert_eq!(runner.hermetic_envs.borrow().len(), 2); + for env in runner.hermetic_envs.borrow().iter() { + assert!( + env.iter().all(|(key, _)| cfg!(windows) + && ["SYSTEMROOT", "TEMP", "TMP", "USERPROFILE"].contains(&key.as_str())), + "{env:?}" + ); + } +} + +#[test] +fn consumer_without_pinned_uv_is_unavailable_and_never_clean() { + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let (code, out, err, _) = run_consumer(&["update"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("backend_unavailable"), "{err}"); + assert!(err.contains("DX_UV_BIN"), "{err}"); + assert!(err.contains("PATH is never searched"), "{err}"); + assert!(!out.contains("lockfile current"), "{out}"); + assert!(runner.calls.borrow().is_empty()); + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let (code, out, err, _) = run_consumer(&["update", "--apply", "deps-worker"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("backend_unavailable"), "{err}"); + assert!(!out.contains("updated deps-worker"), "{out}"); + assert!(runner.calls.borrow().is_empty()); +} + +/// Scripts one Bazel resolution of `rel` under a fresh output base. +fn script_resolution(harness: &Harness, base: &std::path::Path, rel: &str) { + let file = base.join(rel); + std::fs::create_dir_all(file.parent().expect("parent")).expect("dir"); + std::fs::write(&file, b"").expect("tool"); + harness + .query + .outputs + .borrow_mut() + .push(dx_plan::resolve::QueryResult { + code: Some(0), + stdout: format!("{rel}\n").into_bytes(), + stderr: Vec::new(), + }); + harness.query.script_info(&base.to_string_lossy()); +} + +#[test] +fn consumer_unset_uv_resolves_through_bazel_once_for_every_set() { + let base = super::super::super::test_support::temp_dir("uv-fallback"); + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let harness = consumer_harness("fallback", UV_MODULE, true); + script_resolution(&harness, base.path(), "external/x/uv"); + script_resolution(&harness, base.path(), "external/x/python3"); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + let calls = harness.query.calls.borrow(); + assert_eq!(calls.len(), 2, "one cquery per tool: {calls:?}"); + assert!(calls[0].contains(&"@dx_tools//:uv".to_owned()), "{calls:?}"); + assert!( + !calls[0] + .iter() + .any(|arg| arg.contains("repository_disable")) + ); + let launched = runner.calls.borrow(); + assert_eq!(launched.len(), 2, "{launched:?}"); + let uv = base.path().join("external").join("x").join("uv"); + assert_eq!(launched[0][0], uv.to_string_lossy()); + assert_eq!(launched[1][0], uv.to_string_lossy()); +} + +#[test] +fn consumer_offline_check_disables_repository_downloads_for_resolution() { + let base = super::super::super::test_support::temp_dir("uv-offline"); + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let harness = consumer_harness("offline", UV_MODULE, true); + script_resolution(&harness, base.path(), "external/x/uv"); + script_resolution(&harness, base.path(), "external/x/python3"); + let (code, out, err) = run_with(&["update", "--offline"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + let calls = harness.query.calls.borrow(); + assert!(!calls.is_empty()); + for call in calls.iter() { + assert!( + call.contains(&"--repository_disable_download".to_owned()), + "{call:?}" + ); + assert!(!call.contains(&"--nofetch".to_owned()), "{call:?}"); + } +} + +#[test] +fn consumer_failed_resolution_is_unavailable_with_the_detail_and_queried_once() { + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let harness = consumer_harness("resolve-fails", UV_MODULE, true); + harness + .query + .script_failure(1, "ERROR: no such repository '@dx_tools'\n"); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("backend_unavailable"), "{err}"); + assert!(err.contains("DX_UV_BIN"), "{err}"); + assert!(err.contains("PATH is never searched"), "{err}"); + assert!(err.contains("no such repository"), "{err}"); + assert_eq!(harness.query.calls.borrow().len(), 1); + assert!(runner.calls.borrow().is_empty()); +} + +#[test] +fn consumer_resolved_uv_with_the_wrong_version_is_rejected() { + let base = super::super::super::test_support::temp_dir("uv-wrong-version"); + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + runner.version = Some("uv 0.1.0".to_owned()); + let harness = consumer_harness("wrong-version", UV_MODULE, true); + script_resolution(&harness, base.path(), "external/x/uv"); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("backend_unavailable"), "{err}"); + assert!(runner.calls.borrow().is_empty()); +} + +#[test] +fn consumer_rejected_applies_never_query_bazel() { + for flag in ["--offline", "--frozen"] { + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let harness = consumer_harness("rejected", UV_MODULE, true); + let (_, out, err) = run_with( + &["update", "--apply", flag, "deps-worker"], + &runner, + &harness, + ); + assert!( + harness.query.calls.borrow().is_empty(), + "{flag}: {out}{err}" + ); + assert!(harness.query.info_calls.borrow().is_empty()); + } +} + +#[test] +fn consumer_backend_unavailable_reports_its_code() { + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let (code, out, err, _) = run_consumer(&["update", "--output=json"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(out.contains("\"code\":\"backend_unavailable\""), "{out}"); + assert!(!out.contains("\"code\":\"update_failed\""), "{out}"); + for args in [&["update"][..], &["update", "--apply", "deps-worker"][..]] { + let mut runner = ScriptRunner::new(&[]); + runner.pinned = false; + let (code, out, err, _) = run_consumer(args, &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("dx: backend_unavailable:"), "{err}"); + assert!(!err.contains("dx: update_failed:"), "{err}"); + } +} + +#[test] +fn consumer_uv_of_another_version_is_unavailable_and_never_clean() { + for version in [None, Some("uv 0.11.0".to_owned())] { + let mut runner = ScriptRunner::new(&[]); + runner.version = version; + let (code, out, err, _) = run_consumer(&["update"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("backend_unavailable"), "{err}"); + assert!(err.contains("uv 0.13.7"), "{err}"); + assert!(!out.contains("lockfile current"), "{out}"); + assert!(runner.calls.borrow().is_empty()); + } +} + +#[test] +fn consumer_failed_uv_fails_its_set_and_keeps_the_others() { + let runner = ScriptRunner::new(&[("consumer-uv:apps/frontend", Some(1))]); + let (code, out, err, _) = run_consumer(&["update"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("failed to check deps-frontend: updater exited 1"), + "{err}" + ); + assert!( + out.contains("deps-worker lockfile current (services/worker/uv.lock)"), + "{out}" + ); + assert!(!out.contains("deps-frontend lockfile current"), "{out}"); + assert_eq!(runner.calls.borrow().len(), 2); + let runner = ScriptRunner::new(&[("consumer-uv:services/worker", None)]); + let (code, out, err, _) = run_consumer(&["update", "--apply", "deps-worker"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("failed to update deps-worker: updater terminated by signal"), + "{err}" + ); + let mut runner = ScriptRunner::new(&[]); + runner.io_error = true; + let (code, out, err, _) = run_consumer(&["update", "deps-worker"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("failed to launch updater"), "{err}"); +} + +#[test] +fn consumer_uv_settings_outside_the_closure_are_unsupported() { + let harness = consumer_harness("settings", UV_MODULE, true); + harness.write_source( + "apps/frontend/uv.toml", + "exclude-newer = \"2020-01-01T00:00:00Z\"\n", + ); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("apps/frontend/uv.toml exists"), "{err}"); + assert!( + out.contains("deps-worker lockfile current (services/worker/uv.lock)"), + "{out}" + ); + assert_eq!( + *runner.calls.borrow(), + vec![uv_call(true, "services/worker")] + ); + let harness = consumer_harness("tool-uv", UV_MODULE, true); + harness.write_source( + "services/worker/pyproject.toml", + &format!("{PYPROJECT}\n[tool.uv]\nindex-url = \"https://example.invalid/simple\"\n"), + ); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--apply", "deps-worker"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(err.contains("[tool.uv] index-url"), "{err}"); + assert!(runner.calls.borrow().is_empty()); +} + +#[test] +fn consumer_partial_update_restores_its_own_locks() { + let runner = ScriptRunner::new(&[("consumer-uv:services/worker", Some(1))]); + let (code, out, err, _) = run_consumer(&["update", "--apply"], &runner); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("update_recovery"), "{err}"); + assert!(err.contains("dx update --apply deps-worker"), "{err}"); + assert!( + err.contains("git checkout -- apps/frontend/uv.lock"), + "{err}" + ); + assert!(!err.contains("nothing to roll back"), "{err}"); +} + +#[test] +fn consumer_selective_update_runs_one_directory() { + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update", "--apply", "deps-worker"], &runner); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("Running update for deps-worker"), "{out}"); + assert!( + out.contains("updated deps-worker (services/worker/uv.lock)"), + "{out}" + ); + assert!(!out.contains("deps-frontend"), "{out}"); + assert_eq!(err, "", "{err}"); + assert_eq!( + *runner.calls.borrow(), + vec![uv_call(false, "services/worker")] + ); +} + +#[test] +fn consumer_scopes_resolve_to_their_owning_sets() { + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update", "apps/frontend/app/main.py"], &runner); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(runner.calls.borrow().len(), 1); + assert!(runner.calls.borrow()[0].contains(&"apps/frontend".to_owned())); + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update", "//..."], &runner); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(runner.calls.borrow().len(), 2); + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update", "//services/worker/..."], &runner); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(runner.calls.borrow().len(), 1); + assert!(runner.calls.borrow()[0].contains(&"services/worker".to_owned())); +} + +#[test] +fn consumer_unknown_and_unowned_selectors_fail_closed() { + let runner = ScriptRunner::new(&[]); + let (code, _, err, _) = run_consumer(&["update", "--apply", "cargo"], &runner); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("unknown dependency set or scope"), "{err}"); + let runner = ScriptRunner::new(&[]); + let (code, _, err, _) = run_consumer(&["update", "--apply", "docs/cli/README.md"], &runner); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("no owning dependency set"), "{err}"); + assert_eq!(runner.calls.borrow().len(), 0); +} + +#[test] +fn consumer_selective_package_is_unsupported_without_launch() { + let runner = ScriptRunner::new(&[]); + let (code, _, err, _) = run_consumer(&["update", "--apply", "deps-frontend:anyio"], &runner); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(err.contains("deps-frontend"), "{err}"); + assert_eq!(runner.calls.borrow().len(), 0); +} + +#[test] +fn consumer_missing_lock_fails_check_without_launch() { + let harness = consumer_harness("missing-lock", UV_MODULE, false); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with(&["update", "deps-worker"], &runner, &harness); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("services/worker/uv.lock"), "{err}"); + assert!(err.contains("is missing"), "{err}"); + assert_eq!(runner.calls.borrow().len(), 0); +} + +#[test] +fn consumer_check_leaves_the_workspace_untouched() { + let runner = ScriptRunner::new(&[]); + let (code, _, _, harness) = run_consumer(&["update"], &runner); + assert_eq!(code, 0); + assert_eq!( + std::fs::read(harness.workspace.join("apps/frontend/uv.lock")).expect("lock"), + UV_LOCK.as_bytes() + ); + assert_eq!( + std::fs::read(harness.workspace.join("apps/frontend/pyproject.toml")).expect("manifest"), + PYPROJECT.as_bytes() + ); + assert_eq!( + std::fs::read(harness.workspace.join("MODULE.bazel")).expect("module"), + UV_MODULE.as_bytes() + ); +} + +#[test] +fn consumer_offline_update_needs_network() { + let runner = ScriptRunner::new(&[]); + let (code, _, err, _) = + run_consumer(&["update", "--apply", "--offline", "deps-worker"], &runner); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + assert_eq!(runner.calls.borrow().len(), 0); +} + +#[test] +fn consumer_frozen_update_locks_resolution() { + let runner = ScriptRunner::new(&[]); + let (code, _, err, _) = + run_consumer(&["update", "--apply", "--frozen", "deps-worker"], &runner); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + assert!(!err.contains("offline_required"), "{err}"); + assert_eq!(runner.calls.borrow().len(), 0); + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update", "--frozen", "deps-worker"], &runner); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("deps-worker lockfile current (services/worker/uv.lock)"), + "{out}{err}" + ); +} + +#[test] +fn consumer_invalid_config_fails_before_any_backend() { + let harness = consumer_harness( + "invalid", + "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\")\n", + true, + ); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("uv.project"), "{err}"); + assert_eq!(runner.calls.borrow().len(), 0); +} + +#[test] +fn consumer_json_check_reports_per_set_correlations() { + let runner = ScriptRunner::new(&[]); + let (code, out, err, _) = run_consumer(&["update", "--output=json"], &runner); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let kinds = event_kinds(&events); + assert_eq!(kinds[0], "command_started"); + assert_eq!(kinds[kinds.len() - 1], "command_finished"); + assert!( + out.contains("\"correlation\":\"update:deps-frontend\""), + "{out}" + ); + assert!( + out.contains("\"correlation\":\"update:deps-worker\""), + "{out}" + ); + let _ = event(&events, "command_started"); +} + +const MIXED_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\nnpm = use_extension(\"@aspect_rules_js//npm:extensions.bzl\", \"npm\")\nnpm.npm_translate_lock(name = \"jobs\", pnpm_lock = \"//consumer-npm/jobs:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"web\", pnpm_lock = \"//consumer-npm/web:pnpm-lock.yaml\")\n"; + +const PACKAGE_JSON: &str = "{\n \"name\": \"app\",\n \"version\": \"1.0.0\"\n}\n"; + +const PNPM_LOCK: &str = "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n"; + +fn mixed_harness(name: &str) -> Harness { + let harness = consumer_harness(name, MIXED_MODULE, true); + for dir in ["consumer-npm/jobs", "consumer-npm/web"] { + harness.write_source(&format!("{dir}/package.json"), PACKAGE_JSON); + harness.write_source(&format!("{dir}/pnpm-lock.yaml"), PNPM_LOCK); + } + harness +} + +fn npm_env() -> Vec<(String, String)> { + let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; + vec![ + ("npm_config_globalconfig".to_owned(), null.to_owned()), + ("npm_config_userconfig".to_owned(), null.to_owned()), + ] +} + +#[allow(clippy::disallowed_methods)] // test helper mirrors the backend argv +fn npm_call( + harness: &Harness, + startup: &[String], + offline: bool, + dir: &str, + command: &[&str], +) -> Vec { + let mut argv = dx_process::startup_argv(startup); + argv.push("run".to_owned()); + argv.extend(dx_process::build_verb_options( + "run", + &["--config=dx_build_dev".to_owned()], + )); + argv.push("--config=dx_build_dev".to_owned()); + argv.extend(dx_plan::plan::shared_settings("run")); + if offline { + argv.push("--repository_disable_download".to_owned()); + } + for word in ["@pnpm//:pnpm", "--", "--dir"] { + argv.push(word.to_owned()); + } + argv.push(harness.workspace.join(dir).to_string_lossy().into_owned()); + argv.extend(command.iter().map(|word| (*word).to_owned())); + argv +} + +fn npm_check(harness: &Harness, dir: &str) -> Vec { + npm_call( + harness, + &[], + false, + dir, + &["install", "--lockfile-only", "--frozen-lockfile"], + ) +} + +fn read_bytes(harness: &Harness, rel: &str) -> Vec { + std::fs::read(harness.workspace.join(rel)).unwrap_or_else(|error| panic!("{rel}: {error}")) +} + +#[test] +fn consumer_mixed_check_runs_uv_hermetic_and_npm_through_bazel() { + let harness = mixed_harness("mixed-check"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("jobs lockfile current (consumer-npm/jobs/pnpm-lock.yaml)"), + "{out}" + ); + assert!( + out.contains("web lockfile current (consumer-npm/web/pnpm-lock.yaml)"), + "{out}" + ); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 3); + assert_eq!(launches[0].argv, uv_call(true, "apps/frontend")); + assert!(launches[0].hermetic); + assert_eq!( + launches[1..], + [ + Launch { + argv: npm_check(&harness, "consumer-npm/jobs"), + cwd: harness.workspace.clone(), + env: npm_env(), + hermetic: false, + }, + Launch { + argv: npm_check(&harness, "consumer-npm/web"), + cwd: harness.workspace.clone(), + env: npm_env(), + hermetic: false, + }, + ] + ); + drop(launches); + for rel in [ + "consumer-npm/jobs/pnpm-lock.yaml", + "consumer-npm/web/pnpm-lock.yaml", + ] { + assert_eq!(read_bytes(&harness, rel), PNPM_LOCK.as_bytes(), "{rel}"); + } + assert!( + !harness + .workspace + .join("consumer-npm/web/node_modules") + .exists() + ); +} + +#[test] +fn consumer_npm_apply_runs_one_set_and_leaves_the_others() { + let harness = mixed_harness("npm-apply"); + let before: Vec> = [ + "consumer-npm/jobs/package.json", + "consumer-npm/jobs/pnpm-lock.yaml", + "apps/frontend/uv.lock", + ] + .iter() + .map(|rel| read_bytes(&harness, rel)) + .collect(); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--apply", "web"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("updated web (consumer-npm/web/pnpm-lock.yaml)"), + "{out}" + ); + assert_eq!( + *runner.launches.borrow(), + vec![Launch { + argv: npm_call( + &harness, + &[], + false, + "consumer-npm/web", + &["install", "--lockfile-only"] + ), + cwd: harness.workspace.clone(), + env: npm_env(), + hermetic: false, + }] + ); + let after: Vec> = [ + "consumer-npm/jobs/package.json", + "consumer-npm/jobs/pnpm-lock.yaml", + "apps/frontend/uv.lock", + ] + .iter() + .map(|rel| read_bytes(&harness, rel)) + .collect(); + assert_eq!(before, after); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &["update", "--apply", "web:@types/node", "web:react"], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("updated web:@types/node, web:react (consumer-npm/web/pnpm-lock.yaml)"), + "{out}" + ); + assert_eq!( + *runner.calls.borrow(), + vec![npm_call( + &harness, + &[], + false, + "consumer-npm/web", + &["update", "@types/node", "react", "--lockfile-only"] + )] + ); +} + +#[test] +fn consumer_npm_startup_options_and_offline_check_reach_the_bazel_run() { + let harness = mixed_harness("npm-startup"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &[ + "update", + "--offline", + "--bazel-startup-option=--output_base=/tmp/npm-ob", + "web", + ], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + *runner.calls.borrow(), + vec![npm_call( + &harness, + &["--output_base=/tmp/npm-ob".to_owned()], + true, + "consumer-npm/web", + &[ + "install", + "--lockfile-only", + "--frozen-lockfile", + "--offline" + ] + )] + ); +} + +#[test] +fn consumer_npm_bazel_run_sees_the_held_slot() { + let harness = mixed_harness("npm-slot"); + let scratch = dx_test_scratch::scratch("dx-update-consumer-slot-"); + let gate = dx_plan::slots::Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), false); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = dx_plan::slots::with_gate(Some(gate), || { + run_with(&["update", "web"], &runner, &harness) + }); + assert_eq!(code, 0, "{out}{err}"); + let marked = (dx_plan::slots::SLOT_ENV_VAR.to_owned(), "1".to_owned()); + let launches = runner.launches.borrow(); + assert!(!launches.is_empty()); + for launch in launches.iter() { + assert!(launch.env.contains(&marked), "{launch:?}"); + } +} + +#[test] +fn consumer_npm_failures_stay_with_their_own_set() { + let harness = mixed_harness("npm-fail"); + let runner = ScriptRunner::new(&[("consumer-npm:web", Some(1))]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("failed to check web: updater exited 1"), + "{err}" + ); + assert!(!out.contains("web lockfile current"), "{out}"); + assert!( + out.contains("jobs lockfile current (consumer-npm/jobs/pnpm-lock.yaml)"), + "{out}" + ); + assert!( + out.contains("deps-frontend lockfile current (apps/frontend/uv.lock)"), + "{out}" + ); + assert_eq!(runner.calls.borrow().len(), 3); +} + +#[test] +fn consumer_npm_refusals_never_launch_pnpm() { + let harness = mixed_harness("npm-refuse"); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with(&["update", "--apply", "--frozen", "web"], &runner, &harness); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--offline", "web"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, _, err) = run_with(&["update", "web:react"], &runner, &harness); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + let (code, _, err) = run_with(&["update", "--apply", "web:React"], &runner, &harness); + assert_eq!(code, 2, "{err}"); + assert!( + err.contains("invalid package \"React\" for set web"), + "{err}" + ); + assert!(runner.calls.borrow().is_empty()); + std::fs::remove_file(harness.workspace.join("consumer-npm/web/pnpm-lock.yaml")) + .expect("drop lock"); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("consumer-npm/web/pnpm-lock.yaml"), "{err}"); + assert!(err.contains("is missing"), "{err}"); + assert_eq!( + *runner.calls.borrow(), + vec![ + uv_call(true, "apps/frontend"), + npm_check(&harness, "consumer-npm/jobs") + ] + ); +} + +#[test] +fn consumer_npm_parent_pnpm_workspace_is_unsupported() { + let harness = mixed_harness("npm-parent"); + harness.write_source("consumer-npm/pnpm-workspace.yaml", "packages: [\"*\"]\n"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--apply", "web"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!( + err.contains("consumer-npm/pnpm-workspace.yaml makes pnpm write the parent workspace lock"), + "{err}" + ); + assert!(runner.calls.borrow().is_empty()); +} + +const PINNED_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"deps\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\nruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(name = \"gems\", gemfile = \"//apps/rb:Gemfile\", gemfile_lock = \"//apps/rb:Gemfile.lock\")\n"; + +const PINNED_FILES: [(&str, &str); 2] = [ + ( + "apps/rb/Gemfile", + "source \"https://rubygems.org\"\ngem \"rspec-core\"\n", + ), + ( + "apps/rb/Gemfile.lock", + "GEM\n remote: https://rubygems.org/\n specs:\n rspec-core (3.13.0)\n", + ), +]; + +fn pinned_harness(name: &str) -> Harness { + let harness = consumer_harness(name, PINNED_MODULE, true); + for (rel, text) in PINNED_FILES { + harness.write_source(rel, text); + } + harness +} + +fn assert_pinned_files_unchanged(harness: &Harness) { + for (rel, text) in PINNED_FILES { + assert_eq!(read_bytes(harness, rel), text.as_bytes(), "{rel}"); + } +} + +#[test] +fn consumer_ruby_set_reports_pinned_and_launches_nothing() { + for (argv, uv) in [ + (vec!["update"], uv_call(true, "apps/frontend")), + ( + vec!["update", "--offline", "--frozen"], + uv_call(true, "apps/frontend"), + ), + (vec!["update", "--apply"], uv_call(false, "apps/frontend")), + ] { + let harness = pinned_harness("pinned"); + let runner = ScriptRunner::new(&[]); + let mut json = argv.clone(); + json.push("--output=json"); + let (code, out, err) = run_with(&json, &runner, &harness); + let launches: Vec> = runner + .launches + .borrow() + .iter() + .map(|launch| launch.argv.clone()) + .collect(); + assert!( + launches.iter().all(|argv| argv[0] == fake_pinned("uv")), + "only uv runs: {launches:?}" + ); + if !argv.contains(&"--offline") { + assert_eq!(code, 0, "{argv:?} {out}{err}"); + assert_eq!(launches, vec![uv.clone()], "{argv:?}"); + } + let events = json_events(&out); + assert!( + events + .iter() + .any(|event| event["code"] == "update_set_pinned" + && event["correlation"] == "update:gems"), + "{argv:?}: {out}" + ); + assert!(!out.contains("gems lockfile current"), "{out}"); + assert!(!out.contains("updated gems"), "{out}"); + assert_pinned_files_unchanged(&harness); + } + let harness = pinned_harness("pinned-text"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("gems pins are manual; nothing to resolve"), + "{out}" + ); +} + +#[test] +fn consumer_pinned_package_selection_is_unsupported_without_fallback() { + for mode in ["--apply", "--output=text"] { + let harness = pinned_harness("pinned-selective"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", mode, "gems:rspec-core"], &runner, &harness); + assert_ne!(code, 0, "{mode}: {out}{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(err.contains("bundle lock"), "{err}"); + assert!(!out.contains("pins are manual"), "{out}"); + assert!(runner.launches.borrow().is_empty()); + assert_pinned_files_unchanged(&harness); + } +} + +#[test] +fn consumer_pinned_set_without_its_lock_declared_is_unsupported() { + let harness = consumer_harness( + "pinned-lock", + "ruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(name = \"gems\", gemfile = \"//apps/rb:Gemfile\", gemfile_lock = \"//apps/rb:gems.locked\")\n", + true, + ); + harness.write_source("apps/rb/Gemfile", PINNED_FILES[0].1); + harness.write_source("apps/rb/gems.locked", PINNED_FILES[1].1); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("apps/rb/Gemfile.lock"), "{err}"); + assert!(!out.contains("pins are manual"), "{out}"); + assert!(runner.launches.borrow().is_empty()); +} + +const CARGO_MANIFEST: &str = + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n"; + +const CARGO_LOCK: &str = "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.200\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n"; + +const CARGO_FILES: [&str; 6] = [ + "consumer-rs/crates/Cargo.toml", + "consumer-rs/crates/Cargo.lock", + "consumer-rs/crates/cargo-bazel-lock.json", + "consumer-rs/tools/Cargo.toml", + "consumer-rs/tools/Cargo.lock", + "consumer-rs/tools/cargo-bazel-lock.json", +]; + +const CARGO_MODULE: &str = "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//consumer-rs/crates:Cargo.lock\", lockfile = \"//consumer-rs/crates:cargo-bazel-lock.json\", manifests = [\"//consumer-rs/crates:Cargo.toml\"])\ncrate.from_cargo(name = \"tool_crates\", cargo_lockfile = \"//consumer-rs/tools:Cargo.lock\", lockfile = \"//consumer-rs/tools:cargo-bazel-lock.json\", manifests = [\"//consumer-rs/tools:Cargo.toml\"])\nuse_repo(crate, \"crates\", \"tool_crates\")\n"; + +fn cargo_harness(name: &str) -> Harness { + let harness = consumer_harness(name, &format!("{MIXED_MODULE}{CARGO_MODULE}"), true); + for dir in ["consumer-npm/jobs", "consumer-npm/web"] { + harness.write_source(&format!("{dir}/package.json"), PACKAGE_JSON); + harness.write_source(&format!("{dir}/pnpm-lock.yaml"), PNPM_LOCK); + } + for dir in ["consumer-rs/crates", "consumer-rs/tools"] { + harness.write_source(&format!("{dir}/Cargo.toml"), CARGO_MANIFEST); + harness.write_source(&format!("{dir}/Cargo.lock"), CARGO_LOCK); + harness.write_source(&format!("{dir}/cargo-bazel-lock.json"), "{}\n"); + } + harness +} + +#[allow(clippy::disallowed_methods)] // test helper mirrors the backend argv +fn cargo_call(startup: &[String], offline: bool, repository: &str) -> Vec { + let mut argv = dx_process::startup_argv(startup); + argv.push("fetch".to_owned()); + argv.extend(dx_process::build_verb_options( + "fetch", + &["--config=dx_build_dev".to_owned()], + )); + argv.push("--config=dx_build_dev".to_owned()); + if offline { + argv.push("--repository_disable_download".to_owned()); + } + argv.push(format!("--repo=@{repository}")); + argv +} + +fn cargo_env(repin: &str, repository: &str) -> Vec<(String, String)> { + dx_update::backend::cargo_env(repin, repository) +} + +fn cargo_bytes(harness: &Harness) -> Vec> { + CARGO_FILES + .iter() + .map(|rel| read_bytes(harness, rel)) + .collect() +} + +#[test] +fn consumer_cargo_env_forces_both_repin_triggers() { + assert_eq!( + cargo_env("false", "crates"), + vec![ + ("CARGO_BAZEL_ISOLATED".to_owned(), "true".to_owned()), + ("CARGO_BAZEL_REPIN".to_owned(), "false".to_owned()), + ("CARGO_BAZEL_REPIN_ONLY".to_owned(), "crates".to_owned()), + ("REPIN".to_owned(), "false".to_owned()), + ("CARGO_BAZEL_DIGEST_CHECK".to_owned(), "1".to_owned()), + ] + ); +} + +#[test] +fn consumer_cargo_check_env_checks_the_lock_digest_but_apply_does_not() { + let has = |env: Vec<(String, String)>| env.iter().any(|(k, _)| k == "CARGO_BAZEL_DIGEST_CHECK"); + assert!(has(cargo_env("false", "crates"))); + assert!(!has(cargo_env("workspace", "crates"))); + assert!(!has(cargo_env("serde", "crates"))); +} + +#[test] +fn consumer_mixed_check_runs_uv_npm_and_cargo_each_their_own_way() { + let harness = cargo_harness("cargo-check"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("crates lockfile current (consumer-rs/crates/Cargo.lock, consumer-rs/crates/cargo-bazel-lock.json)"), + "{out}" + ); + assert!( + out.contains("web lockfile current (consumer-npm/web/pnpm-lock.yaml)"), + "{out}" + ); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 5, "{launches:?}"); + assert_eq!(launches[1].argv, uv_call(true, "apps/frontend")); + assert!(launches[1].hermetic); + assert_eq!( + launches[0], + Launch { + argv: cargo_call(&[], false, "crates"), + cwd: harness.workspace.clone(), + env: cargo_env("false", "crates"), + hermetic: false, + } + ); + assert_eq!(launches[2].argv, npm_check(&harness, "consumer-npm/jobs")); + assert_eq!(launches[2].env, npm_env()); + assert_eq!( + launches[3], + Launch { + argv: cargo_call(&[], false, "tool_crates"), + cwd: harness.workspace.clone(), + env: cargo_env("false", "tool_crates"), + hermetic: false, + } + ); + assert_eq!(launches[4].argv, npm_check(&harness, "consumer-npm/web")); +} + +#[test] +fn consumer_cargo_apply_repins_one_set_and_leaves_the_others() { + let harness = cargo_harness("cargo-apply"); + let others = [ + "consumer-rs/tools/Cargo.lock", + "consumer-rs/tools/cargo-bazel-lock.json", + "consumer-npm/web/pnpm-lock.yaml", + "apps/frontend/uv.lock", + ]; + let before: Vec> = others.iter().map(|rel| read_bytes(&harness, rel)).collect(); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--apply", "crates"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("updated crates (consumer-rs/crates/Cargo.lock, consumer-rs/crates/cargo-bazel-lock.json)"), + "{out}" + ); + assert_eq!( + *runner.launches.borrow(), + vec![Launch { + argv: cargo_call(&[], false, "crates"), + cwd: harness.workspace.clone(), + env: cargo_env("workspace", "crates"), + hermetic: false, + }] + ); + let after: Vec> = others.iter().map(|rel| read_bytes(&harness, rel)).collect(); + assert_eq!(before, after); +} + +#[test] +fn consumer_cargo_apply_package_repins_one_crate() { + let harness = cargo_harness("cargo-apply-package"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--apply", "crates:serde"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("updated crates:serde (consumer-rs/crates/Cargo.lock, consumer-rs/crates/cargo-bazel-lock.json)"), + "{out}" + ); + assert_eq!( + *runner.launches.borrow(), + vec![Launch { + argv: cargo_call(&[], false, "crates"), + cwd: harness.workspace.clone(), + env: cargo_env("serde", "crates"), + hermetic: false, + }] + ); +} + +#[test] +fn consumer_cargo_package_and_full_sets_in_one_run() { + let harness = cargo_harness("cargo-package-and-full"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &["update", "--apply", "crates:serde", "tool_crates"], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 2); + assert_eq!(launches[0].argv, cargo_call(&[], false, "crates")); + assert_eq!(launches[0].env, cargo_env("serde", "crates")); + assert_eq!(launches[1].argv, cargo_call(&[], false, "tool_crates")); + assert_eq!(launches[1].env, cargo_env("workspace", "tool_crates")); +} + +#[test] +fn consumer_cargo_package_failure_stays_with_its_own_set() { + let harness = cargo_harness("cargo-package-fail"); + let runner = ScriptRunner::new(&[("consumer-cargo:crates", Some(1))]); + let (code, out, err) = run_with( + &["update", "--apply", "crates:serde", "tool_crates"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("updater exited 1"), "{err}"); + assert!(out.contains("updated tool_crates"), "{out}"); + assert_eq!(runner.calls.borrow().len(), 2); +} + +#[test] +fn consumer_cargo_failure_stays_with_its_own_set() { + let harness = cargo_harness("cargo-fail"); + let runner = ScriptRunner::new(&[("consumer-cargo:tool_crates", Some(1))]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("failed to check tool_crates: updater exited 1"), + "{err}" + ); + assert!(!out.contains("tool_crates lockfile current"), "{out}"); + assert!(out.contains("crates lockfile current"), "{out}"); + assert!(out.contains("web lockfile current"), "{out}"); + assert_eq!(runner.calls.borrow().len(), 5); +} + +#[test] +fn consumer_cargo_refusals_never_launch_bazel() { + let harness = cargo_harness("cargo-refuse"); + let before = cargo_bytes(&harness); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with( + &["update", "--apply", "--frozen", "crates"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--offline", "crates"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, out, err) = run_with(&["update", "crates:serde"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(err.contains("select the set without a package"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--frozen", "crates:serde"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--offline", "crates:serde"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "crates:serde", "crates:tokio"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(err.contains("one crate per run"), "{err}"); + for name in ["off", "all"] { + let (code, _, err) = run_with( + &["update", "--apply", &format!("crates:{name}")], + &runner, + &harness, + ); + assert_eq!(code, 1, "{name}: {err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(err.contains("repin mode"), "{err}"); + } + assert!(runner.calls.borrow().is_empty()); + assert_eq!(cargo_bytes(&harness), before); +} + +#[test] +fn consumer_cargo_offline_check_disables_downloads() { + let harness = cargo_harness("cargo-offline"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &[ + "update", + "--offline", + "--bazel-startup-option=--output_base=/tmp/cargo-ob", + "crates", + ], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + *runner.calls.borrow(), + vec![cargo_call( + &["--output_base=/tmp/cargo-ob".to_owned()], + true, + "crates" + )] + ); +} + +#[test] +fn consumer_bazel_check_that_writes_fails_and_is_restored() { + let harness = cargo_harness("cargo-guard"); + let before = cargo_bytes(&harness); + let runner = ScriptRunner::new(&[]); + runner.writes.borrow_mut().push(( + harness.workspace.join("consumer-rs/crates/Cargo.lock"), + b"rewritten\n".to_vec(), + )); + let (code, out, err) = run_with(&["update", "crates"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("failed to check crates: check_wrote: consumer-rs/crates/Cargo.lock (restored); declare `lockfile` in crate.from_cargo"), + "{err}" + ); + assert!(!out.contains("crates lockfile current"), "{out}"); + assert_eq!(cargo_bytes(&harness), before); + let runner = ScriptRunner::new(&[]); + runner.writes.borrow_mut().push(( + harness.workspace.join("consumer-npm/web/pnpm-lock.yaml"), + b"rewritten\n".to_vec(), + )); + let (code, out, err) = run_with(&["update", "web"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains( + "failed to check web: check_wrote: consumer-npm/web/pnpm-lock.yaml (restored)" + ), + "{err}" + ); + assert!(!err.contains("crate.from_cargo"), "{err}"); + assert_eq!( + read_bytes(&harness, "consumer-npm/web/pnpm-lock.yaml"), + PNPM_LOCK.as_bytes() + ); +} + +const MAVEN_MODULE: &str = "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"tool_maven\", lock_file = \"//tools/jvm:tool_install.json\")\nuse_repo(maven, \"maven\", \"tool_maven\")\n"; + +const MAVEN_LOCK: &str = "{\"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY\":\"x\",\"__INPUT_ARTIFACTS_HASH\":1,\"__RESOLVED_ARTIFACTS_HASH\":2,\"artifacts\":{}}\n"; + +const MAVEN_FILES: [&str; 3] = [ + "MODULE.bazel", + "third_party/maven_install.json", + "tools/jvm/tool_install.json", +]; + +fn maven_harness(name: &str) -> Harness { + let harness = consumer_harness(name, &format!("{UV_MODULE}{MAVEN_MODULE}"), true); + harness.write_source("third_party/maven_install.json", MAVEN_LOCK); + harness.write_source("tools/jvm/tool_install.json", MAVEN_LOCK); + harness +} + +#[allow(clippy::disallowed_methods)] // test helper mirrors the backend argv +fn maven_pin_call(repository: &str) -> Vec { + let mut argv = dx_process::startup_argv(&[]); + argv.push("run".to_owned()); + argv.push("--config=dx_build_dev".to_owned()); + argv.push(format!("@{repository}//:pin")); + argv +} + +fn maven_bytes(harness: &Harness) -> Vec> { + MAVEN_FILES + .iter() + .map(|rel| read_bytes(harness, rel)) + .collect() +} + +#[test] +fn consumer_maven_check_fetches_each_hub_without_repin_env() { + let harness = maven_harness("maven-check"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "maven", "tool_maven"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("maven lockfile current (third_party/maven_install.json)"), + "{out}" + ); + let launches = runner.launches.borrow(); + assert_eq!( + *launches, + vec![ + Launch { + argv: cargo_call(&[], false, "maven"), + cwd: harness.workspace.clone(), + env: Vec::new(), + hermetic: false, + }, + Launch { + argv: cargo_call(&[], false, "tool_maven"), + cwd: harness.workspace.clone(), + env: Vec::new(), + hermetic: false, + }, + ] + ); +} + +#[test] +fn consumer_maven_apply_pins_one_set_and_leaves_the_other_lock() { + let harness = maven_harness("maven-apply"); + let before = maven_bytes(&harness); + let runner = ScriptRunner::new(&[]); + runner.writes.borrow_mut().push(( + harness.workspace.join("third_party/maven_install.json"), + b"{\"pinned\":true}\n".to_vec(), + )); + let (code, out, err) = run_with(&["update", "--apply", "maven"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("updated maven"), "{out}"); + assert_eq!( + *runner.launches.borrow(), + vec![Launch { + argv: maven_pin_call("maven"), + cwd: harness.workspace.clone(), + env: vec![("RULES_JVM_EXTERNAL_REPIN".to_owned(), "1".to_owned())], + hermetic: false, + }] + ); + let after = maven_bytes(&harness); + assert_eq!(after[0], before[0]); + assert_ne!(after[1], before[1]); + assert_eq!(after[2], before[2]); +} + +#[test] +fn consumer_maven_refusals_never_launch_bazel() { + let harness = maven_harness("maven-refusals"); + for argv in [ + vec!["update", "--apply", "maven:com.google.guava:guava"], + vec!["update", "--apply", "--offline", "maven"], + vec!["update", "maven:com.google.guava:guava"], + ] { + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&argv, &runner, &harness); + assert_ne!(code, 0, "{argv:?}: {out}{err}"); + assert!(runner.launches.borrow().is_empty(), "{argv:?}"); + } + let runner = ScriptRunner::new(&[]); + let (_, out, err) = run_with( + &["update", "--apply", "maven:com.google.guava:guava"], + &runner, + &harness, + ); + assert!( + format!("{out}{err}").contains("maven pins are exact in MODULE.bazel"), + "{out}{err}" + ); +} + +#[test] +fn consumer_maven_check_needs_the_hub_and_a_signed_lock() { + for (name, rewrite) in [ + ("empty", ("third_party/maven_install.json", "")), + ( + "v1", + ("third_party/maven_install.json", "{\"dependency_tree\":{}}"), + ), + ] { + let harness = maven_harness(&format!("maven-unready-{name}")); + harness.write_source(rewrite.0, rewrite.1); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "maven"], &runner, &harness); + assert_ne!(code, 0, "{name}: {out}{err}"); + assert!(runner.launches.borrow().is_empty(), "{name}"); + assert!(!out.contains("lockfile current"), "{name}: {out}"); + assert!( + format!("{out}{err}").contains("cannot check maven"), + "{name}: {out}{err}" + ); + } +} + +#[test] +fn consumer_maven_check_that_writes_is_restored_and_failed() { + let harness = maven_harness("maven-guard"); + let before = maven_bytes(&harness); + let runner = ScriptRunner::new(&[]); + runner.writes.borrow_mut().push(( + harness.workspace.join("third_party/maven_install.json"), + b"rewritten\n".to_vec(), + )); + let (code, out, err) = run_with(&["update", "maven"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("check_wrote: third_party/maven_install.json (restored)"), + "{err}" + ); + assert_eq!(maven_bytes(&harness), before); +} + +#[test] +fn consumer_maven_failing_pin_fails_only_its_own_set() { + let harness = maven_harness("maven-fail"); + let key = ScriptRunner::key_for(&maven_pin_call("maven")); + let runner = ScriptRunner::new(&[(key.as_str(), Some(1))]); + let (code, out, err) = run_with( + &["update", "--apply", "maven", "tool_maven"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{out}{err}"); + assert!( + format!("{out}{err}").contains("failed to update maven"), + "{out}{err}" + ); + assert!(out.contains("updated tool_maven"), "{out}{err}"); + assert_eq!(runner.launches.borrow().len(), 2); +} + +const NUGET_FILES: [&str; 6] = [ + "MODULE.bazel", + "third_party/dotnet/paket.dependencies", + "third_party/dotnet/paket.lock", + "third_party/dotnet/deps/paket.main.bzl", + "third_party/dotnet_tools/paket.lock", + "third_party/dotnet_tools/deps/paket.tools.bzl", +]; + +fn nuget_harness(name: &str) -> Harness { + let harness = consumer_harness( + name, + &format!( + "{UV_MODULE}paket = use_extension(\"//third_party/dotnet/deps:paket.main_extension.bzl\", \"main_extension\")\n" + ), + true, + ); + for dir in ["dotnet", "dotnet_tools"] { + harness.write_source( + &format!("third_party/{dir}/paket.dependencies"), + "source https://api.nuget.org/v3/index.json\nnuget Newtonsoft.Json\n", + ); + harness.write_source(&format!("third_party/{dir}/paket.lock"), "NUGET\n"); + } + harness.write_source("third_party/dotnet/deps/paket.main.bzl", "main\n"); + harness.write_source("third_party/dotnet_tools/deps/paket.tools.bzl", "tools\n"); + harness +} + +fn nuget_bytes(harness: &Harness) -> Vec> { + NUGET_FILES + .iter() + .map(|rel| read_bytes(harness, rel)) + .collect() +} + +fn stage(runner: &ScriptRunner, files: &[(&str, &str)]) { + runner.staged_outputs.borrow_mut().extend( + files + .iter() + .map(|(name, text)| ((*name).to_owned(), text.as_bytes().to_vec())), + ); +} + +/// The `--output-folder` the single recorded launch ran with. +fn scratch_out(runner: &ScriptRunner) -> std::path::PathBuf { + let launches = runner.launches.borrow(); + let argv = &launches[0].argv; + let at = argv + .iter() + .position(|arg| arg == "--output-folder") + .expect("folder"); + std::path::PathBuf::from(&argv[at + 1]) +} + +#[test] +fn consumer_nuget_check_without_drift_is_current_and_cleans_scratch() { + let harness = nuget_harness("nuget-current"); + let before = nuget_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage( + &runner, + &[ + ("paket.main.bzl", "main\n"), + ("paket.main_extension.bzl", "ext\n"), + ], + ); + let (code, out, err) = run_with(&["update", "nuget"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("nuget lockfile current"), "{out}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1); + let argv = &launches[0].argv; + #[allow(clippy::disallowed_methods)] + let mut head = dx_process::startup_argv(&[]); + head.extend(strings_of(&[ + "run", + "--config=dx_build_dev", + "@rules_dx//dotnet:paket2bazel", + "--", + "--dependencies-file", + ])); + assert_eq!(argv[..head.len()], head[..], "{argv:?}"); + assert_eq!(argv.len(), head.len() + 3); + assert_eq!(argv[head.len() + 1], "--output-folder"); + assert_eq!(launches[0].cwd, harness.workspace); + assert!(launches[0].env.is_empty()); + assert!(!launches[0].hermetic); + let deps = std::path::Path::new(&argv[head.len()]); + assert!(!deps.starts_with(&harness.workspace), "{deps:?}"); + assert!(!deps.exists(), "scratch must be removed: {deps:?}"); + assert_eq!(nuget_bytes(&harness), before); +} + +fn strings_of(words: &[&str]) -> Vec { + words.iter().map(|word| (*word).to_owned()).collect() +} + +#[test] +fn consumer_nuget_check_with_drift_fails_listing_files_and_writes_nothing() { + let harness = nuget_harness("nuget-drift"); + let before = nuget_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage( + &runner, + &[ + ("paket.main.bzl", "new\n"), + ("paket.main_extension.bzl", "ext\n"), + ], + ); + let (code, out, err) = run_with(&["update", "nuget"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("stale generated locks: third_party/dotnet/deps/paket.main.bzl;"), + "{err}" + ); + assert!(!err.contains("paket.main_extension.bzl"), "{err}"); + assert!(!out.contains("lockfile current"), "{out}"); + assert_eq!(nuget_bytes(&harness), before); +} + +#[test] +fn consumer_nuget_apply_writes_only_differing_declared_files() { + let harness = nuget_harness("nuget-apply"); + let before = nuget_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage( + &runner, + &[ + ("paket.main.bzl", "new\n"), + ("paket.main_extension.bzl", "ext\n"), + ], + ); + let (code, out, err) = run_with(&["update", "--apply", "nuget"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("updated nuget"), "{out}"); + let after = nuget_bytes(&harness); + for (index, rel) in NUGET_FILES.iter().enumerate() { + if *rel == "third_party/dotnet/deps/paket.main.bzl" { + assert_eq!(after[index], b"new\n", "{rel}"); + } else { + assert_eq!(after[index], before[index], "{rel}"); + } + } + assert!( + !harness + .workspace + .join("third_party/dotnet/deps/paket.main_extension.bzl") + .exists() + ); + assert!(!scratch_out(&runner).exists()); +} + +const HEX_MODULE: &str = "elixir = use_extension(\"//elixir/toolchain:extensions.bzl\", \"elixir\")\nelixir.lock(name = \"deps\", lock = \"//third_party/elixir:mix.lock\")\n"; + +fn hex_harness(name: &str) -> Harness { + let harness = consumer_harness(name, HEX_MODULE, false); + harness.write_source("third_party/elixir/mix.exs", "defmodule App do\nend\n"); + harness.write_source("third_party/elixir/mix.lock", "%{}\n"); + harness +} + +#[test] +fn consumer_hex_check_reports_drift_and_apply_writes_only_mix_lock() { + let harness = hex_harness("hex-apply"); + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("mix.lock", "%{\"jason\" => 1}\n")]); + let (code, out, err) = run_with(&["update", "deps"], &runner, &harness); + assert_ne!(code, 0, "{out}{err}"); + assert_eq!( + read_bytes(&harness, "third_party/elixir/mix.lock"), + b"%{}\n" + ); + + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("mix.lock", "%{\"jason\" => 1}\n")]); + let (code, out, err) = run_with(&["update", "--apply", "deps"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read_bytes(&harness, "third_party/elixir/mix.lock"), + b"%{\"jason\" => 1}\n" + ); + assert_eq!( + read_bytes(&harness, "third_party/elixir/mix.exs"), + b"defmodule App do\nend\n" + ); + let launches = runner.launches.borrow(); + assert!(launches[0].argv.iter().any(|arg| arg == "deps.update")); +} + +#[test] +fn consumer_mixed_mix_and_rebar_run_still_checks_the_mix_set() { + let module = format!( + "{HEX_MODULE}erlang = use_extension(\"//erlang/toolchain:repos.bzl\", \"erlang\")\nerlang.rebar(name = \"erl\", lock = \"//third_party/erlang:rebar.lock\")\n" + ); + let harness = consumer_harness("hex-mixed", &module, false); + harness.write_source("third_party/elixir/mix.exs", "defmodule App do\nend\n"); + harness.write_source("third_party/elixir/mix.lock", "%{}\n"); + harness.write_source("third_party/erlang/rebar.config", "{deps, []}.\n"); + harness.write_source("third_party/erlang/rebar.lock", "[].\n"); + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("mix.lock", "%{\"jason\" => 1}\n")]); + let (code, out, err) = run_with(&["update"], &runner, &harness); + assert_ne!(code, 0, "{out}{err}"); + let launches = runner.launches.borrow(); + assert!(launches[0].argv.iter().any(|arg| arg == "deps.get")); + assert_eq!( + read_bytes(&harness, "third_party/erlang/rebar.lock"), + b"[].\n" + ); +} + +const OPAM_MODULE: &str = "ocaml = use_extension(\"//ocaml/toolchain:extensions.bzl\", \"ocaml\")\nocaml.lock(name = \"ocaml_app\", lock = \"//ocaml/app:ocaml_app.opam.locked\")\n"; + +const OPAM_FILE: &str = "opam-version: \"2.0\"\ndepends: [\n \"astring\" {>= \"0.8.5\"}\n \"ocaml\" {>= \"5.5\"}\n]\n"; + +fn opam_harness(name: &str) -> Harness { + let harness = consumer_harness(name, OPAM_MODULE, false); + harness.write_source("ocaml/app/ocaml_app.opam", OPAM_FILE); + harness.write_source( + "ocaml/app/ocaml_app.opam.locked", + "opam-version: \"2.0\"\ndepends: [\n \"astring\" {= \"0.8.5\"}\n]\n", + ); + harness +} + +#[test] +fn consumer_opam_edits_the_constraint_and_runs_upgrade_then_lock() { + let harness = opam_harness("opam-edit"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &["update", "--apply", "ocaml_app:astring", "0.9.0"], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read_bytes(&harness, "ocaml/app/ocaml_app.opam"), + OPAM_FILE.replace("0.8.5", "0.9.0").as_bytes() + ); + let launches = runner.launches.borrow(); + assert!( + launches + .iter() + .any(|run| run.argv.iter().any(|a| a == "upgrade")) + ); + assert!( + launches + .iter() + .any(|run| run.argv.iter().any(|a| a == "lock")) + ); +} + +#[test] +fn consumer_opam_refuses_an_undeclared_package_and_writes_nothing() { + let harness = opam_harness("opam-undeclared"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &["update", "--apply", "ocaml_app:lwt", "5.7.0"], + &runner, + &harness, + ); + assert_ne!(code, 0, "{out}{err}"); + assert!(runner.launches.borrow().is_empty()); + assert_eq!( + read_bytes(&harness, "ocaml/app/ocaml_app.opam"), + OPAM_FILE.as_bytes() + ); +} + +#[test] +fn consumer_hex_offline_needs_network_and_launches_nothing() { + let harness = hex_harness("hex-offline"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--offline", "deps"], &runner, &harness); + assert_ne!(code, 0, "{out}{err}"); + assert!(runner.launches.borrow().is_empty()); +} + +#[test] +fn consumer_nuget_undeclared_or_missing_output_fails_and_writes_nothing() { + for (name, files, want) in [ + ( + "undeclared", + vec![ + ("paket.main.bzl", "new\n"), + ("paket.main_extension.bzl", "ext2\n"), + ("paket.extra.bzl", "x\n"), + ], + "not declared in locks: paket.extra.bzl", + ), + ( + "missing", + vec![("paket.main_extension.bzl", "ext\n")], + "did not generate declared locks: third_party/dotnet/deps/paket.main.bzl", + ), + ] { + let harness = nuget_harness(&format!("nuget-{name}")); + let before = nuget_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage(&runner, &files); + let (code, out, err) = run_with(&["update", "--apply", "nuget"], &runner, &harness); + assert_eq!(code, 1, "{name}: {out}{err}"); + assert!(err.contains(want), "{name}: {err}"); + assert_eq!(nuget_bytes(&harness), before, "{name}"); + } +} + +#[test] +fn consumer_nuget_failing_run_writes_nothing() { + let harness = nuget_harness("nuget-fail"); + let before = nuget_bytes(&harness); + let key = ScriptRunner::key_for(&strings_of(&["paket2bazel"])); + let runner = ScriptRunner::new(&[(key.as_str(), Some(1))]); + stage( + &runner, + &[ + ("paket.main.bzl", "new\n"), + ("paket.main_extension.bzl", "ext\n"), + ], + ); + let (code, out, err) = run_with(&["update", "--apply", "nuget"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + format!("{out}{err}").contains("failed to update nuget"), + "{out}{err}" + ); + assert_eq!(nuget_bytes(&harness), before); +} + +#[test] +fn consumer_nuget_offline_is_required_and_frozen_apply_runs() { + let harness = nuget_harness("nuget-offline"); + for argv in [ + vec!["update", "--offline", "nuget"], + vec!["update", "--apply", "--offline", "nuget"], + vec!["update", "--apply", "nuget:Newtonsoft.Json"], + ] { + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&argv, &runner, &harness); + assert_ne!(code, 0, "{argv:?}: {out}{err}"); + assert!(runner.launches.borrow().is_empty(), "{argv:?}"); + assert!( + !format!("{out}{err}").contains("lockfile current"), + "{argv:?}" + ); + } + let runner = ScriptRunner::new(&[]); + let (_, out, err) = run_with(&["update", "--offline", "nuget"], &runner, &harness); + assert!( + format!("{out}{err}").contains("offline_required"), + "{out}{err}" + ); + let runner = ScriptRunner::new(&[]); + stage( + &runner, + &[ + ("paket.main.bzl", "main\n"), + ("paket.main_extension.bzl", "ext\n"), + ], + ); + let (code, out, err) = run_with( + &["update", "--apply", "--frozen", "nuget"], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(runner.launches.borrow().len(), 1); +} + +#[test] +fn consumer_nuget_layout_refusal_never_launches() { + let harness = nuget_harness("nuget-layout"); + harness.write_source( + "third_party/dotnet/paket.dependencies", + "source ../local-feed\n", + ); + for mode in ["--output=text", "--apply"] { + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", mode, "nuget"], &runner, &harness); + assert_ne!(code, 0, "{out}{err}"); + assert!(runner.launches.borrow().is_empty()); + assert!(format!("{out}{err}").contains("not http(s)"), "{out}{err}"); + } +} + +const GO_FILES: [&str; 3] = [ + "MODULE.bazel", + "third_party/go/go.mod", + "third_party/go/go.sum", +]; + +const GO_MOD: &str = "module example.com/m\n\ngo 1.22\n\nrequire github.com/google/go-cmp v0.7.0\n"; + +fn go_harness(name: &str) -> Harness { + let harness = consumer_harness( + name, + &format!( + "{UV_MODULE}bazel_dep(name = \"rules_go\", version = \"0.50.1\")\ngo_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//third_party/go:go.mod\")\n" + ), + true, + ); + harness.write_source("third_party/go/go.mod", GO_MOD); + harness.write_source("third_party/go/go.sum", "old\n"); + harness +} + +fn go_bytes(harness: &Harness) -> Vec> { + GO_FILES + .iter() + .map(|rel| read_bytes(harness, rel)) + .collect() +} + +/// The `-C` directory the single recorded launch ran in. +fn go_scratch_out(runner: &ScriptRunner) -> std::path::PathBuf { + let launches = runner.launches.borrow(); + let argv = &launches[0].argv; + let at = argv.iter().position(|arg| arg == "-C").expect("-C"); + std::path::PathBuf::from(&argv[at + 1]) +} + +#[test] +fn consumer_go_check_runs_go_in_scratch_with_forced_env_and_cleans_up() { + let harness = go_harness("go-current"); + let before = go_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("go.sum", "old\n"), ("go.mod", GO_MOD)]); + let (code, out, err) = run_with(&["update", "go"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("go lockfile current"), "{out}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1); + let argv = &launches[0].argv; + #[allow(clippy::disallowed_methods)] + let mut head = dx_process::startup_argv(&[]); + head.extend(strings_of(&[ + "run", + "--config=dx_build_dev", + "@rules_go//go", + "--", + "-C", + ])); + assert_eq!(argv[..head.len()], head[..], "{argv:?}"); + assert_eq!( + argv[head.len() + 1..], + strings_of(&["mod", "download", "all"])[..], + "{argv:?}" + ); + let out_dir = std::path::PathBuf::from(&argv[head.len()]); + let scratch = out_dir.parent().expect("scratch").to_path_buf(); + assert!(out_dir.ends_with("out")); + assert!(!scratch.starts_with(&harness.workspace)); + assert_eq!(launches[0].cwd, harness.workspace); + assert!(!launches[0].hermetic); + let env: std::collections::BTreeMap<&str, &str> = launches[0] + .env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + assert_eq!(env["GOENV"], "off"); + assert_eq!(env["GOWORK"], "off"); + assert_eq!(env["GOTOOLCHAIN"], "local"); + assert_eq!(env["GOFLAGS"], "-modcacherw"); + for key in ["GOMODCACHE", "GOPATH", "GOCACHE"] { + let path = std::path::Path::new(env[key]); + assert!( + path.starts_with(&scratch) && !path.starts_with(&out_dir), + "{key}" + ); + } + assert!(!scratch.exists(), "scratch must be removed: {scratch:?}"); + assert_eq!(go_bytes(&harness), before); +} + +#[test] +fn consumer_go_check_with_drift_fails_and_writes_nothing() { + let harness = go_harness("go-drift"); + let before = go_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("go.sum", "new\n"), ("go.mod", GO_MOD)]); + let (code, out, err) = run_with(&["update", "go"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("stale generated locks: third_party/go/go.sum;"), + "{err}" + ); + assert!(!out.contains("lockfile current"), "{out}"); + assert_eq!(go_bytes(&harness), before); +} + +#[test] +fn consumer_go_apply_writes_only_go_sum() { + let harness = go_harness("go-apply"); + let before = go_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("go.sum", "new\n"), ("go.mod", GO_MOD)]); + let (code, out, err) = run_with(&["update", "--apply", "go"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("updated go"), "{out}"); + let after = go_bytes(&harness); + assert_eq!(after[0], before[0]); + assert_eq!(after[1], before[1]); + assert_eq!(after[2], b"new\n"); + assert!(!go_scratch_out(&runner).exists()); +} + +#[test] +fn consumer_go_changed_go_mod_fails_and_writes_nothing() { + { + let (name, files, want) = ( + "mod", + vec![("go.sum", "new\n"), ("go.mod", "module changed\n")], + "go changed go.mod", + ); + let harness = go_harness(&format!("go-{name}")); + let before = go_bytes(&harness); + let runner = ScriptRunner::new(&[]); + stage(&runner, &files); + let (code, out, err) = run_with(&["update", "--apply", "go"], &runner, &harness); + assert_eq!(code, 1, "{name}: {out}{err}"); + assert!(err.contains(want), "{name}: {err}"); + assert_eq!(go_bytes(&harness), before, "{name}"); + } +} + +#[test] +fn consumer_go_failing_run_fails_only_its_set() { + let harness = go_harness("go-fail"); + let before = go_bytes(&harness); + let key = ScriptRunner::key_for(&strings_of(&["@rules_go//go"])); + let runner = ScriptRunner::new(&[(key.as_str(), Some(1))]); + stage(&runner, &[("go.sum", "new\n"), ("go.mod", GO_MOD)]); + let (code, out, err) = run_with(&["update", "--apply", "go"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + format!("{out}{err}").contains("failed to update go"), + "{out}{err}" + ); + assert_eq!(go_bytes(&harness), before); +} + +#[test] +fn consumer_go_offline_and_frozen_apply_never_launch() { + let harness = go_harness("go-offline"); + for (argv, want) in [ + (vec!["update", "--offline", "go"], "offline_required"), + ( + vec!["update", "--apply", "--offline", "go"], + "offline_required", + ), + (vec!["update", "--apply", "--frozen", "go"], "frozen_locked"), + ( + vec!["update", "--apply", "go:github.com/google/go-cmp"], + "whole module graph", + ), + ] { + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&argv, &runner, &harness); + assert_ne!(code, 0, "{argv:?}: {out}{err}"); + assert!(runner.launches.borrow().is_empty(), "{argv:?}"); + let text = format!("{out}{err}"); + assert!(text.contains(want), "{argv:?}: {text}"); + assert!(!text.contains("lockfile current"), "{argv:?}"); + } + // A frozen check only compares, so it still runs. + let runner = ScriptRunner::new(&[]); + stage(&runner, &[("go.sum", "old\n"), ("go.mod", GO_MOD)]); + let (code, out, err) = run_with(&["update", "--frozen", "go"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(runner.launches.borrow().len(), 1); +} + +#[test] +fn consumer_go_layout_refusal_never_launches() { + let harness = go_harness("go-layout"); + harness.write_source( + "third_party/go/go.mod", + &format!("{GO_MOD}\nreplace example.com/x => ../x\n"), + ); + for mode in ["--output=text", "--apply"] { + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", mode, "go"], &runner, &harness); + assert_ne!(code, 0, "{out}{err}"); + assert!(runner.launches.borrow().is_empty()); + assert!(format!("{out}{err}").contains("local path"), "{out}{err}"); + } +} + +#[cfg(unix)] +#[test] +fn scratch_removal_survives_a_read_only_module_cache() { + use std::os::unix::fs::PermissionsExt; + let scratch = crate::exec::staged::Scratch::create("readonly").expect("scratch"); + let dir = scratch.0.join("gomodcache/pkg/mod"); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(dir.join("f.go"), "package f\n").expect("file"); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).expect("chmod"); + let path = scratch.0.clone(); + drop(scratch); + assert!(!path.exists(), "{path:?}"); +} + +#[cfg(unix)] +#[test] +fn consumer_check_with_a_lock_linking_outside_fails_before_launch() { + for (argv, lock) in [ + (["update", "crates"], "consumer-rs/crates/Cargo.lock"), + (["update", "web"], "consumer-npm/web/pnpm-lock.yaml"), + ] { + let harness = cargo_harness("link-out"); + let outside = harness + .workspace + .parent() + .expect("parent") + .join("outside-lock.txt"); + std::fs::write(&outside, b"outside\n").expect("outside"); + let at = harness.workspace.join(lock); + std::fs::remove_file(&at).expect("remove lock"); + std::os::unix::fs::symlink(&outside, &at).expect("link"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&argv, &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("leaves the workspace"), "{err}"); + assert!(runner.launches.borrow().is_empty()); + assert_eq!(std::fs::read(&outside).expect("outside"), b"outside\n"); + } +} + +#[cfg(unix)] +#[test] +fn consumer_check_that_writes_through_an_inside_link_is_restored() { + let harness = cargo_harness("link-in"); + let at = harness.workspace.join("consumer-rs/crates/Cargo.lock"); + let real = harness.workspace.join("consumer-rs/crates/real.lock"); + std::fs::rename(&at, &real).expect("rename"); + std::os::unix::fs::symlink(&real, &at).expect("link"); + let before = std::fs::read(&real).expect("real"); + let runner = ScriptRunner::new(&[]); + runner + .writes + .borrow_mut() + .push((at.clone(), b"rewritten\n".to_vec())); + let (code, out, err) = run_with(&["update", "crates"], &runner, &harness); + assert_eq!(code, 1, "{out}{err}"); + assert!( + err.contains("check_wrote: consumer-rs/crates/Cargo.lock (restored)"), + "{err}" + ); + assert!( + std::fs::symlink_metadata(&at) + .expect("meta") + .file_type() + .is_symlink() + ); + assert_eq!(std::fs::read(&real).expect("real"), before); +} + +#[test] +fn consumer_cargo_wrong_manifest_name_exits_two() { + let harness = consumer_harness( + "cargo-manifest", + "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//rs:Cargo.lock\", manifests = [\"//rs:cargo.toml\"])\n", + true, + ); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with(&["update"], &runner, &harness); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("must be named Cargo.toml"), "{err}"); + assert!(runner.calls.borrow().is_empty()); +} + +const SWIFT_MODULE: &str = "swift_deps = use_extension(\"@rules_swift_package_manager//:extensions.bzl\", \"swift_deps\")\nswift_deps.from_package(resolved = \"//apps/swift:Package.resolved\", swift = \"//apps/swift:Package.swift\")\n"; +const SWIFT_MANIFEST: &str = "apps/swift/Package.swift"; + +fn swift_harness(name: &str) -> Harness { + let harness = consumer_harness(name, SWIFT_MODULE, true); + harness.write_source( + SWIFT_MANIFEST, + "import PackageDescription\nlet package = Package(name: \"app\", dependencies: [\n .package(url: \"https://github.com/apple/swift-argument-parser\", exact: \"1.8.2\"),\n .package(url: \"https://github.com/apple/swift-log.git\", branch: \"main\"),\n], targets: [])\n", + ); + harness.write_source( + "apps/swift/Package.resolved", + "{\"pins\":[],\"version\":3}\n", + ); + harness +} + +#[test] +fn consumer_swiftpm_apply_edits_the_manifest_then_updates_the_pin() { + let harness = swift_harness("swift-apply"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &[ + "update", + "--apply", + "swiftpm:swift-argument-parser", + "1.9.0", + ], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + let manifest = String::from_utf8(read_bytes(&harness, SWIFT_MANIFEST)).expect("utf8"); + assert!(manifest.contains("exact: \"1.9.0\""), "{manifest}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1, "{launches:?}"); + let argv = &launches[0].argv; + assert_eq!( + argv[argv.len() - 4..], + strings_of(&["--", "package", "update", "swift-argument-parser"])[..], + "{argv:?}" + ); +} + +#[test] +fn consumer_swiftpm_refusals_launch_nothing_and_keep_the_manifest() { + let harness = swift_harness("swift-refuse"); + let before = read_bytes(&harness, SWIFT_MANIFEST); + let runner = ScriptRunner::new(&[]); + for package in ["swift-log", "missing"] { + let (code, _, err) = run_with( + &["update", "--apply", &format!("swiftpm:{package}"), "1.0.0"], + &runner, + &harness, + ); + assert_ne!(code, 0, "{package}: {err}"); + } + let (code, _, err) = run_with( + &[ + "update", + "--apply", + "--frozen", + "swiftpm:swift-argument-parser", + "1.9.0", + ], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + assert_eq!(read_bytes(&harness, SWIFT_MANIFEST), before); + assert!(runner.launches.borrow().is_empty()); +} + +const CPAN_MODULE: &str = "cpan = use_extension(\"@rules_perl//perl/cpan:extensions.bzl\", \"cpan\")\ncpan.install(name = \"cpan_deps\", lock = \"//perl/third_party:cpanfile.snapshot.lock.json\")\n"; +const CPAN_FILES: [&str; 3] = [ + "perl/third_party/cpanfile", + "perl/third_party/cpanfile.snapshot", + "perl/third_party/cpanfile.snapshot.lock.json", +]; + +fn cpan_harness(name: &str) -> Harness { + let harness = consumer_harness(name, CPAN_MODULE, true); + harness.write_source( + "perl/third_party/cpanfile", + "requires 'Text::CSV', '== 2.06';\n", + ); + harness.write_source( + "perl/third_party/cpanfile.snapshot", + "# carton snapshot format: version 1.0\nDISTRIBUTIONS\n Text-CSV-2.06\n pathname: I/IS/ISHIGAKI/Text-CSV-2.06.tar.gz\n", + ); + harness.write_source( + "perl/third_party/cpanfile.snapshot.lock.json", + "{\"Text-CSV\":{\"strip_prefix\":\"Text-CSV-2.06\"}}\n", + ); + harness.write_source( + "perl/third_party/BUILD.bazel", + "cpan_compiler(name = \"compiler\", cpanfile = \"cpanfile\", lockfile = \"cpanfile.snapshot.lock.json\")\n", + ); + harness +} + +fn cpan_bytes(harness: &Harness) -> Vec> { + CPAN_FILES + .iter() + .map(|rel| read_bytes(harness, rel)) + .collect() +} + +fn carton_tail(argv: &[String]) -> &[String] { + let at = argv + .iter() + .position(|arg| arg == "@rules_dx//perl/tools:carton") + .expect("carton target"); + &argv[at..] +} + +#[test] +fn consumer_cpan_check_installs_the_snapshot_in_deployment_mode() { + let harness = cpan_harness("cpan-check"); + let before = cpan_bytes(&harness); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "cpan_deps"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1, "{launches:?}"); + let argv = &launches[0].argv; + assert!( + argv.iter() + .any(|arg| arg.starts_with("--run_under=cd '") && arg.contains("perl/third_party")), + "{argv:?}" + ); + assert_eq!( + carton_tail(argv), + &strings_of(&[ + "@rules_dx//perl/tools:carton", + "--", + "install", + "--deployment" + ])[..], + "{argv:?}" + ); + assert_eq!(launches[0].env[0].0, "PERL_CARTON_PATH"); + assert_eq!(cpan_bytes(&harness), before); +} + +#[test] +fn consumer_cpan_apply_module_runs_carton_then_the_compiler() { + let harness = cpan_harness("cpan-apply"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &["update", "--apply", "cpan_deps:Text::CSV"], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 2, "{launches:?}"); + assert_eq!( + carton_tail(&launches[0].argv), + &strings_of(&["@rules_dx//perl/tools:carton", "--", "update", "Text::CSV"])[..] + ); + assert_eq!( + launches[1].argv[launches[1].argv.len() - 3..], + strings_of(&["//perl/third_party:compiler", "--", "--incremental"])[..] + ); +} + +#[test] +fn consumer_cpan_refusals_launch_nothing() { + let harness = cpan_harness("cpan-refuse"); + let before = cpan_bytes(&harness); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with(&["update", "--offline", "cpan_deps"], &runner, &harness); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--frozen", "cpan_deps:Text::CSV"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--offline", "cpan_deps:Text::CSV"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, _, err) = run_with(&["update", "cpan_deps:Text::CSV"], &runner, &harness); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_set_unsupported"), "{err}"); + assert!(runner.launches.borrow().is_empty()); + assert_eq!(cpan_bytes(&harness), before); +} + +const CABAL_MODULE: &str = "haskell = use_extension(\"//haskell/toolchain:extensions.bzl\", \"haskell\")\nhaskell.lock(name = \"hackage\", lock = \"//hs:haskell_deps.json\")\n"; +const CABAL_FILES: [&str; 4] = [ + "hs/cabal.project", + "hs/cabal.project.freeze", + "hs/haskell_deps.json", + "hs/app.cabal", +]; + +fn cabal_harness(name: &str) -> Harness { + let harness = consumer_harness(name, CABAL_MODULE, true); + harness.write_source("hs/cabal.project", "packages: .\n"); + harness.write_source( + "hs/cabal.project.freeze", + "constraints: any.base ==4.20.2.0,\n any.safe ==0.3.21\nindex-state: hackage.haskell.org 2026-09-30T23:43:34Z\n", + ); + harness.write_source( + "hs/haskell_deps.json", + "{\"packages\":[{\"name\":\"base\",\"version\":\"4.20.2.0\"},{\"name\":\"safe\",\"version\":\"0.3.21\"}]}\n", + ); + harness.write_source( + "hs/app.cabal", + "cabal-version: 3.0\nname: app\nversion: 0.1.0.0\nbuild-type: Simple\n\nlibrary\n build-depends: base, safe ==0.3.21\n", + ); + harness.write_source( + "hs/BUILD.bazel", + "haskell_deps_resolve(name = \"resolve\")\nhaskell_deps_resolve_test(name = \"resolve_test\")\n", + ); + harness +} + +fn cabal_bytes(harness: &Harness) -> Vec> { + CABAL_FILES + .iter() + .map(|rel| read_bytes(harness, rel)) + .collect() +} + +#[test] +fn consumer_cabal_check_runs_the_resolve_test() { + let harness = cabal_harness("cabal-check"); + let before = cabal_bytes(&harness); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "hackage"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1, "{launches:?}"); + assert_eq!( + launches[0].argv.last().map(String::as_str), + Some("//hs:resolve_test") + ); + assert_eq!(cabal_bytes(&harness), before); +} + +#[test] +fn consumer_cabal_apply_runs_the_resolve_target() { + let harness = cabal_harness("cabal-apply"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with(&["update", "--apply", "hackage"], &runner, &harness); + assert_eq!(code, 0, "{out}{err}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1, "{launches:?}"); + assert_eq!( + launches[0].argv.last().map(String::as_str), + Some("//hs:resolve") + ); +} + +#[test] +fn consumer_cabal_apply_package_rewrites_the_bound_then_resolves() { + let harness = cabal_harness("cabal-bound"); + let runner = ScriptRunner::new(&[]); + let (code, out, err) = run_with( + &["update", "--apply", "hackage:safe", "0.3.22"], + &runner, + &harness, + ); + assert_eq!(code, 0, "{out}{err}"); + let cabal = String::from_utf8(read_bytes(&harness, "hs/app.cabal")).expect("utf8"); + assert!(cabal.contains("safe ==0.3.22"), "{cabal}"); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1, "{launches:?}"); + assert_eq!( + launches[0].argv.last().map(String::as_str), + Some("//hs:resolve") + ); +} + +#[test] +fn consumer_cabal_refusals_launch_nothing() { + let harness = cabal_harness("cabal-refuse"); + let before = cabal_bytes(&harness); + let runner = ScriptRunner::new(&[]); + let (code, _, err) = run_with(&["update", "--offline", "hackage"], &runner, &harness); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "--frozen", "hackage"], + &runner, + &harness, + ); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + let (code, _, err) = run_with( + &["update", "--apply", "hackage:missing", "1.0"], + &runner, + &harness, + ); + assert_ne!(code, 0, "{err}"); + assert!(runner.launches.borrow().is_empty()); + assert_eq!(cabal_bytes(&harness), before); +} diff --git a/cli/cli/src/exec/update_version.rs b/cli/cli/src/exec/update_version.rs index dcc4a0edd0..0a3fd9e84c 100644 --- a/cli/cli/src/exec/update_version.rs +++ b/cli/cli/src/exec/update_version.rs @@ -506,1869 +506,5 @@ fn emit_update_no_change( } #[cfg(test)] -mod tests { - use super::super::test_support::*; - use super::*; - - const HELLO_CARGO: &str = "rust/tests/fixtures/hello/Cargo.toml"; - - /// A root MODULE.bazel whose dx_crates hub reads the given Cargo.toml labels. - fn cargo_module(labels: &[&str]) -> String { - let manifests = labels - .iter() - .map(|label| format!(" \"{label}\",\n")) - .collect::(); - format!( - "bazel_dep(name = \"rules_rust\", version = \"0.74.0\")\n\ncrate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(\n name = \"dx_crates\",\n cargo_lockfile = \"//rust/tests/fixtures/hello:Cargo.lock\",\n manifests = [\n{manifests} ],\n)\n" - ) - } - - /// Writes the cargo manifests and a MODULE.bazel hub that lists all of them. - fn seed_cargo(harness: &Harness, manifests: &[(&str, &str)]) { - let labels: Vec = manifests - .iter() - .map(|(path, _)| { - let (package, file) = path.rsplit_once('/').expect("package path"); - format!("//{package}:{file}") - }) - .collect(); - let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); - harness.write_source("MODULE.bazel", &cargo_module(&labels)); - for (path, text) in manifests { - harness.write_source(path, text); - } - } - - /// Writes one manifest; a Cargo.toml also gets the MODULE.bazel hub that lists it. - fn seed(harness: &Harness, path: &str, text: &str) { - if path.ends_with("Cargo.toml") { - seed_cargo(harness, &[(path, text)]); - } else { - harness.write_source(path, text); - } - } - - const CONSUMER_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"pypi\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\n"; - - const CONSUMER_PYPROJECT: &str = r#" -[project] -name = "frontend" -version = "0.1.0" -requires-python = ">=3.9" -dependencies = ["anyio>=4"] -"#; - - #[test] - fn consumer_configured_uv_set_needs_a_package_and_widens_pyproject() { - let harness = Harness::new("update-consumer"); - harness.write_source("MODULE.bazel", CONSUMER_MODULE); - harness.write_source("apps/frontend/pyproject.toml", CONSUMER_PYPROJECT); - let (code, _, err) = harness.run(&["update", "pypi-frontend", "4.1.0", "--apply"]); - assert_ne!(code, 0, "{err}"); - assert!( - err.contains("update needs one package, not a whole set"), - "{err}" - ); - assert_eq!( - std::fs::read(harness.workspace.join("apps/frontend/pyproject.toml")) - .expect("manifest"), - CONSUMER_PYPROJECT.as_bytes() - ); - assert!(harness.seen_env.borrow().is_empty()); - // The refresh cannot run offline, but the widen is refused before any write. - let (code, _, err) = harness.run(&[ - "update", - "pypi-frontend:anyio", - "4.1.0", - "--apply", - "--offline", - ]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("without network"), "{err}"); - assert_eq!( - std::fs::read(harness.workspace.join("apps/frontend/pyproject.toml")) - .expect("manifest"), - CONSUMER_PYPROJECT.as_bytes() - ); - } - - const PINNED_MODULE: &str = "ruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(name = \"gems\", gemfile = \"//apps/rb:Gemfile\", gemfile_lock = \"//apps/rb:Gemfile.lock\")\n"; - - #[test] - fn consumer_ruby_set_refuses_an_unversioned_gem_without_mutation() { - let harness = Harness::new("update-consumer-pinned"); - harness.write_source("MODULE.bazel", PINNED_MODULE); - let files = [ - ( - "apps/rb/Gemfile", - "source \"https://rubygems.org\"\ngem \"rack\"\n", - ), - ("apps/rb/Gemfile.lock", "GEM\n specs:\n rack (3.0.0)\n"), - ]; - for (rel, text) in files { - harness.write_source(rel, text); - } - let (code, _, err) = harness.run(&["update", "gems:rack", "1.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("the gem line needs exactly one literal version requirement"), - "{err}" - ); - for (rel, text) in files { - assert_eq!( - std::fs::read(harness.workspace.join(rel)).expect("unchanged"), - text.as_bytes(), - "{rel}" - ); - } - assert!(harness.seen_env.borrow().is_empty()); - } - - const COMPOSER_MODULE: &str = "composer = use_extension(\"@rules_dx//php/composer:extensions.bzl\", \"composer\")\ncomposer.lock(name = \"vendor\", digests = \"//apps/php:composer.digests.json\", lock = \"//apps/php:composer.lock\")\n"; - const COMPOSER_JSON: &str = "{\n \"require\": {\n \"psr/log\": \"^1.1\"\n }\n}\n"; - - fn seed_composer_set(harness: &Harness) { - harness.write_source("MODULE.bazel", COMPOSER_MODULE); - harness.write_source("apps/php/composer.json", COMPOSER_JSON); - harness.write_source("apps/php/composer.lock", "{\"packages\":[]}\n"); - harness.write_source("apps/php/composer.digests.json", "{\"packages\":{}}\n"); - } - - #[test] - fn consumer_composer_set_refuses_an_undeclared_package_without_mutation() { - let harness = Harness::new("update-consumer-composer-undeclared"); - seed_composer_set(&harness); - let (code, _, err) = harness.run(&["update", "vendor:acme/other", "1.0.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert_unchanged(&harness, &[("apps/php/composer.json", COMPOSER_JSON)]); - assert!(harness.seen_env.borrow().is_empty()); - } - - #[test] - fn consumer_composer_set_widens_the_constraint_and_refreshes() { - let harness = Harness::new("update-consumer-composer"); - seed_composer_set(&harness); - let (code, out, err) = harness.run(&["update", "vendor:psr/log", "3.0.2", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read(&harness, "apps/php/composer.json"), - COMPOSER_JSON.replace("^1.1", "^3.0.2") - ); - assert!(!harness.build_argv.borrow().is_empty()); - } - - const CPAN_MODULE: &str = "cpan = use_extension(\"@rules_perl//perl/cpan:extensions.bzl\", \"cpan\")\ncpan.install(name = \"cpan_deps\", lock = \"//pl:cpanfile.snapshot.lock.json\")\n"; - const CPANFILE: &str = "requires 'Text::CSV', '== 2.06';\n"; - - fn seed_cpan_set(harness: &Harness) { - harness.write_source("MODULE.bazel", CPAN_MODULE); - harness.write_source("pl/cpanfile", CPANFILE); - harness.write_source( - "pl/cpanfile.snapshot", - "# carton snapshot format: version 1.0\nDISTRIBUTIONS\n Text-CSV-2.06\n", - ); - harness.write_source( - "pl/cpanfile.snapshot.lock.json", - "{\"Text-CSV\":{\"strip_prefix\":\"Text-CSV-2.06\"}}\n", - ); - harness.write_source( - "pl/BUILD.bazel", - "cpan_compiler(name = \"compiler\", cpanfile = \"cpanfile\", lockfile = \"cpanfile.snapshot.lock.json\")\n", - ); - } - - #[test] - fn consumer_cpan_set_widens_the_cpanfile_and_refreshes() { - let harness = Harness::new("update-consumer-cpan"); - seed_cpan_set(&harness); - let (code, out, err) = harness.run(&["update", "cpan_deps:Text::CSV", "2.07", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read(&harness, "pl/cpanfile"), - CPANFILE.replace("2.06", "2.07") - ); - let runs = harness.build_argv.borrow(); - assert_eq!(runs.len(), 2, "{runs:?}"); - assert!( - runs[0] - .iter() - .any(|arg| arg == "@rules_dx//perl/tools:carton"), - "{:?}", - runs[0] - ); - assert!( - runs[1].iter().any(|arg| arg == "//pl:compiler"), - "{:?}", - runs[1] - ); - } - - #[test] - fn consumer_cpan_set_refusals_write_nothing_and_launch_nothing() { - let harness = Harness::new("update-consumer-cpan-refuse"); - seed_cpan_set(&harness); - let (code, _, err) = harness.run(&["update", "cpan_deps:Other::Mod", "1.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - let (code, _, err) = harness.run(&[ - "update", - "cpan_deps:Text::CSV", - "2.07", - "--apply", - "--frozen", - ]); - assert_eq!(code, 1, "{err}"); - assert_unchanged(&harness, &[("pl/cpanfile", CPANFILE)]); - assert!(harness.build_argv.borrow().is_empty()); - } - - const CARGO_A: &str = - "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n"; - const CARGO_B: &str = - "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\nanyhow = \"1\"\n"; - - const CARGO_SET_MODULE: &str = "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//rs/a:Cargo.lock\", manifests = [\"//rs/a:Cargo.toml\"])\ncrate.from_cargo(name = \"tools\", cargo_lockfile = \"//rs/b:Cargo.lock\", manifests = [\"//rs/b:Cargo.toml\"])\nuse_repo(crate, \"crates\", \"tools\")\n"; - - fn seed_cargo_set(harness: &Harness) { - harness.write_source("MODULE.bazel", CARGO_SET_MODULE); - harness.write_source("rs/a/Cargo.toml", CARGO_A); - harness.write_source("rs/b/Cargo.toml", CARGO_B); - harness.write_source("rs/a/Cargo.lock", "version = 4\n"); - harness.write_source("rs/b/Cargo.lock", "version = 4\n"); - } - - fn assert_unchanged(harness: &Harness, files: &[(&str, &str)]) { - for (rel, text) in files { - assert_eq!( - std::fs::read(harness.workspace.join(rel)).expect("unchanged"), - text.as_bytes(), - "{rel}" - ); - } - } - - #[test] - fn consumer_cargo_set_widens_its_manifest_only_and_repins_the_workspace() { - let harness = Harness::new("update-consumer-cargo"); - seed_cargo_set(&harness); - let (code, out, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("widened crates:serde to 2.0.0 in rs/a/Cargo.toml"), - "{out}" - ); - assert!( - out.contains("refreshed crates via `dx update crates`"), - "{out}" - ); - assert_eq!( - read(&harness, "rs/a/Cargo.toml"), - CARGO_A.replace("\"1\"", "\"2.0.0\"") - ); - assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); - let runs = harness.build_argv.borrow(); - assert_eq!(runs.len(), 1, "{runs:?}"); - assert!(runs[0].iter().any(|arg| arg == "fetch"), "{:?}", runs[0]); - assert_eq!(runs[0].last().map(String::as_str), Some("--repo=@crates")); - assert_eq!( - *harness.seen_env.borrow(), - vec![dx_update::backend::cargo_env("workspace", "crates")] - ); - assert_eq!(*harness.seen_cwd.borrow(), vec![harness.workspace.clone()]); - } - - #[test] - fn consumer_cargo_set_over_two_directories_widens_every_declaring_manifest_and_repins_once() { - let harness = Harness::new("update-consumer-cargo-multi"); - seed_cargo_set(&harness); - harness.write_source("rs/c/Cargo.toml", CARGO_A); - harness.write_source( - "MODULE.bazel", - &CARGO_SET_MODULE.replace( - "[\"//rs/a:Cargo.toml\"]", - "[\"//rs/a:Cargo.toml\", \"//rs/c:Cargo.toml\"]", - ), - ); - let (code, out, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - let widened = CARGO_A.replace("\"1\"", "\"2.0.0\""); - assert_eq!(read(&harness, "rs/a/Cargo.toml"), widened); - assert_eq!(read(&harness, "rs/c/Cargo.toml"), widened); - assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); - assert_eq!(harness.build_argv.borrow().len(), 1); - } - - #[test] - fn consumer_cargo_set_missing_requirement_is_added_to_the_first_manifest_and_repins() { - let harness = Harness::new("update-consumer-cargo-missing"); - seed_cargo_set(&harness); - let (code, out, err) = harness.run(&["update", "crates:anyhow", "2.0.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("added crates:anyhow at 2.0.0 to rs/a/Cargo.toml"), - "{out}" - ); - assert!(read(&harness, "rs/a/Cargo.toml").contains("anyhow = \"2.0.0\"")); - assert_unchanged(&harness, &[("rs/b/Cargo.toml", CARGO_B)]); - assert_eq!(harness.build_argv.borrow().len(), 1); - } - - #[test] - fn consumer_cargo_set_widens_only_the_manifests_that_declare_the_crate() { - let harness = Harness::new("update-consumer-cargo-partial"); - seed_cargo_set(&harness); - harness.write_source("rs/c/Cargo.toml", CARGO_B); - harness.write_source( - "MODULE.bazel", - &CARGO_SET_MODULE.replace( - "[\"//rs/a:Cargo.toml\"]", - "[\"//rs/a:Cargo.toml\", \"//rs/c:Cargo.toml\"]", - ), - ); - let (code, out, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read(&harness, "rs/a/Cargo.toml"), - CARGO_A.replace("\"1\"", "\"2.0.0\"") - ); - assert_unchanged( - &harness, - &[("rs/b/Cargo.toml", CARGO_B), ("rs/c/Cargo.toml", CARGO_B)], - ); - assert_eq!(harness.build_argv.borrow().len(), 1); - } - - #[test] - fn consumer_cargo_set_refusals_write_nothing_and_launch_nothing() { - let harness = Harness::new("update-consumer-cargo-refuse"); - seed_cargo_set(&harness); - let files = [ - ("rs/a/Cargo.toml", CARGO_A), - ("rs/b/Cargo.toml", CARGO_B), - ("rs/a/Cargo.lock", "version = 4\n"), - ("rs/b/Cargo.lock", "version = 4\n"), - ]; - let (code, _, err) = - harness.run(&["update", "crates:serde", "2.0.0", "--offline", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - let (code, _, err) = - harness.run(&["update", "crates:serde", "2.0.0", "--frozen", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - let (code, _, err) = harness.run(&["update", "crates", "2.0.0", "--apply"]); - assert_eq!(code, 2, "{err}"); - let (code, _, err) = harness.run(&[ - "update", - "crates:serde", - "2.0.0", - "--floor", - "--reason", - "x", - "--apply", - ]); - assert_ne!(code, 0, "{err}"); - assert_unchanged(&harness, &files); - harness.write_source( - "MODULE.bazel", - &CARGO_SET_MODULE.replace("//rs/a:Cargo.lock", "//rs/a:other.lock"), - ); - let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("no widen performed"), "{err}"); - assert_unchanged(&harness, &files); - assert!(harness.build_argv.borrow().is_empty()); - assert!(harness.seen_env.borrow().is_empty()); - } - - #[test] - fn consumer_cargo_set_offline_json_fails_before_widening() { - let harness = Harness::new("update-consumer-cargo-offline-json"); - seed_cargo_set(&harness); - let (code, out, err) = harness.run(&[ - "update", - "crates:serde", - "2.0.0", - "--offline", - "--output=json", - "--apply", - ]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("no widen performed"), "{err}"); - assert_eq!(read(&harness, "rs/a/Cargo.toml"), CARGO_A); - assert!(harness.seen_env.borrow().is_empty()); - let events = json_events(&out); - let errors = events_of_kind(&events, "error"); - assert_eq!(errors[0]["code"], serde_json::json!("offline_required")); - let finished = events.last().expect("finished"); - assert_eq!(finished["exit_code"], 1); - assert_eq!(finished["results_complete"], false); - } - - #[test] - fn file_only_updates_work_offline_with_json_reports() { - for (selector, version, path, before, after) in [ - ( - "bazel:.bazelversion", - "9.3.0", - ".bazelversion", - "9.2.0\n", - "9.3.0\n", - ), - ( - "github-actions:actions/checkout", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ".github/workflows/ci.yml", - "jobs:\n b:\n steps:\n - uses: actions/checkout@v1\n", - "jobs:\n b:\n steps:\n - uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", - ), - ] { - let harness = Harness::new("update-file-offline"); - harness.write_source(path, before); - let (code, out, err) = harness.run(&[ - "update", - selector, - version, - "--offline", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{selector}: {out}{err}"); - assert_eq!(read(&harness, path), after); - assert!(harness.seen_env.borrow().is_empty()); - let events = json_events(&out); - let notices = events_of_kind(&events, "notice"); - assert!( - notices.iter().any(|n| n["code"] == "update_widened"), - "{out}" - ); - assert_eq!(events.last().expect("finished")["exit_code"], 0); - } - } - - #[test] - fn consumer_cargo_set_refresh_failure_keeps_the_widen_and_check_reports_drift() { - let mut harness = Harness::new("update-consumer-cargo-fail"); - harness.bazel_code = 3; - seed_cargo_set(&harness); - let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update drift"), "{err}"); - assert_unchanged(&harness, &[("rs/a/Cargo.toml", CARGO_A)]); - assert!(harness.build_argv.borrow().is_empty()); - let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(read(&harness, "rs/a/Cargo.toml").contains("2.0.0")); - assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); - } - - #[test] - fn consumer_cargo_set_launch_failure_keeps_the_widen() { - let mut harness = Harness::new("update-consumer-cargo-launch-fail"); - harness.io_error = true; - seed_cargo_set(&harness); - let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_failed"), "{err}"); - assert!(err.contains("widen kept"), "{err}"); - assert!(read(&harness, "rs/a/Cargo.toml").contains("2.0.0")); - assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); - } - - #[test] - fn consumer_cargo_set_signalled_refresh_keeps_the_widen() { - let mut harness = Harness::new("update-consumer-cargo-signalled"); - harness.signalled = true; - seed_cargo_set(&harness); - let (code, out, err) = harness.run(&[ - "update", - "crates:serde", - "2.0.0", - "--output=json", - "--apply", - ]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("signal"), "{err}"); - assert!(out.contains("update_widened"), "{out}"); - assert!(read(&harness, "rs/a/Cargo.toml").contains("2.0.0")); - } - - #[test] - fn consumer_cargo_set_json_reports_widen_then_refresh() { - let harness = Harness::new("update-consumer-cargo-json"); - seed_cargo_set(&harness); - let (code, out, err) = harness.run(&[ - "update", - "crates:serde", - "2.0.0", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let kinds = event_kinds(&events); - assert_eq!(kinds[0], "command_started"); - assert_eq!(kinds[kinds.len() - 1], "command_finished"); - let notices = events_of_kind(&events, "notice"); - assert_eq!(notices.len(), 2, "{out}"); - assert_eq!(notices[0]["code"], serde_json::json!("update_widened")); - assert_eq!(notices[1]["code"], serde_json::json!("update_set_success")); - assert_eq!(err, "", "{err}"); - } - - #[test] - fn consumer_cargo_set_json_refresh_failure_reports_widen_then_error() { - let mut harness = Harness::new("update-consumer-cargo-json-fail"); - harness.bazel_code = 2; - seed_cargo_set(&harness); - let (code, out, err) = harness.run(&[ - "update", - "crates:serde", - "2.0.0", - "--output=json", - "--apply", - ]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("update_failed"), "{err}"); - let kinds_owned = event_kinds(&json_events(&out)).join(","); - assert!(kinds_owned.contains("notice"), "{out}"); - assert!(kinds_owned.contains("error"), "{out}"); - } - - const MAVEN_MODULE: &str = "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(\n name = \"maven\",\n artifacts = [\"junit:junit:4.13.2\"],\n lock_file = \"//third_party:maven_install.json\",\n)\nmaven.install(\n name = \"other\",\n artifacts = [\"junit:junit:4.12\"],\n)\nuse_repo(maven, \"maven\", \"other\")\n"; - - fn seed_maven_set(harness: &Harness) { - harness.write_source("MODULE.bazel", MAVEN_MODULE); - harness.write_source("third_party/maven_install.json", "{}\n"); - } - - #[test] - fn consumer_maven_set_edits_only_its_own_install_and_repins() { - let harness = Harness::new("update-consumer-maven"); - seed_maven_set(&harness); - let (code, out, err) = harness.run(&["update", "maven:junit:junit", "4.13.3", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("refreshed maven via `dx update maven`"), - "{out}" - ); - assert_eq!( - read(&harness, "MODULE.bazel"), - MAVEN_MODULE.replace("junit:junit:4.13.2", "junit:junit:4.13.3") - ); - let runs = harness.build_argv.borrow(); - assert_eq!(runs.len(), 1, "{runs:?}"); - assert!(runs[0].iter().any(|arg| arg == "run"), "{:?}", runs[0]); - assert_eq!(runs[0].last().map(String::as_str), Some("@maven//:pin")); - assert_eq!( - *harness.seen_env.borrow(), - vec![dx_update::backend::maven_env(true)] - ); - } - - #[test] - fn consumer_maven_set_refusals_write_nothing_and_launch_nothing() { - let harness = Harness::new("update-consumer-maven-refuse"); - seed_maven_set(&harness); - let files = [ - ("MODULE.bazel", MAVEN_MODULE), - ("third_party/maven_install.json", "{}\n"), - ]; - for (flag, want) in [ - ("--offline", "offline_required"), - ("--frozen", "frozen_locked"), - ] { - let (code, _, err) = - harness.run(&["update", "maven:junit:junit", "4.13.3", flag, "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains(want), "{err}"); - } - assert_unchanged(&harness, &files); - let module = MAVEN_MODULE.replace("//third_party:maven_install.json", "//:other.json"); - harness.write_source("MODULE.bazel", &module); - let (code, _, err) = harness.run(&["update", "maven:junit:junit", "4.13.3", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("no widen performed"), "{err}"); - assert_unchanged(&harness, &[("MODULE.bazel", &module), files[1]]); - assert!(harness.build_argv.borrow().is_empty()); - assert!(harness.seen_env.borrow().is_empty()); - } - - #[test] - fn consumer_maven_set_refresh_failure_keeps_the_widen() { - let mut harness = Harness::new("update-consumer-maven-fail"); - harness.bazel_code = 3; - seed_maven_set(&harness); - let (code, _, err) = harness.run(&["update", "maven:junit:junit", "4.13.3", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_failed"), "{err}"); - assert!(read(&harness, "MODULE.bazel").contains("junit:junit:4.13.3")); - assert_eq!(read(&harness, "third_party/maven_install.json"), "{}\n"); - } - - #[test] - fn consumer_maven_set_json_reports_widen_then_refresh() { - let harness = Harness::new("update-consumer-maven-json"); - seed_maven_set(&harness); - let (code, out, err) = harness.run(&[ - "update", - "maven:junit:junit", - "4.13.3", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let notices = events_of_kind(&events, "notice"); - assert_eq!(notices.len(), 2, "{out}"); - assert_eq!(notices[0]["code"], serde_json::json!("update_widened")); - assert_eq!(notices[1]["code"], serde_json::json!("update_set_success")); - } - - const NPM_MODULE: &str = "npm = use_extension(\"@aspect_rules_js//npm:extensions.bzl\", \"npm\")\nnpm.npm_translate_lock(name = \"web\", pnpm_lock = \"//web:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"jobs\", pnpm_lock = \"//jobs:pnpm-lock.yaml\")\n"; - const NPM_PACKAGE: &str = - "{\n \"name\": \"app\",\n \"dependencies\": {\n \"left-pad\": \"1.0.0\"\n }\n}\n"; - const NPM_LOCK: &str = "lockfileVersion: '9.0'\n"; - - fn seed_npm_set(harness: &Harness, web_package: &str) { - harness.write_source("MODULE.bazel", NPM_MODULE); - harness.write_source("web/package.json", web_package); - harness.write_source("jobs/package.json", NPM_PACKAGE); - for dir in ["web", "jobs"] { - harness.write_source(&format!("{dir}/pnpm-lock.yaml"), NPM_LOCK); - } - } - - #[test] - fn consumer_npm_set_widens_one_package_and_chains_a_selective_refresh() { - let harness = Harness::new("update-consumer-npm"); - seed_npm_set(&harness, NPM_PACKAGE); - let (code, out, err) = harness.run(&["update", "web:left-pad", "1.3.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("refreshed web:left-pad via `dx update web:left-pad`"), - "{out}" - ); - assert_eq!( - read(&harness, "web/package.json"), - NPM_PACKAGE.replace("1.0.0", "1.3.0") - ); - assert_unchanged( - &harness, - &[ - ("jobs/package.json", NPM_PACKAGE), - ("jobs/pnpm-lock.yaml", NPM_LOCK), - ], - ); - let runs = harness.build_argv.borrow(); - assert_eq!(runs.len(), 1, "{runs:?}"); - assert!(runs[0].iter().any(|arg| arg == "left-pad"), "{:?}", runs[0]); - } - - #[test] - fn consumer_npm_set_minified_manifest_changes_only_the_dependency() { - let harness = Harness::new("update-consumer-npm-minified"); - let minified = - "{\"name\":\"app\",\"dependencies\":{\"left-pad\":\"1.0.0\",\"zod\":\"3.0.0\"}}"; - seed_npm_set(&harness, minified); - let (code, out, err) = harness.run(&["update", "web:left-pad", "1.3.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read(&harness, "web/package.json"), - minified.replace("\"left-pad\":\"1.0.0\"", "\"left-pad\":\"1.3.0\"") - ); - } - - #[test] - fn consumer_npm_set_refresh_failure_keeps_the_widen() { - let mut harness = Harness::new("update-consumer-npm-fail"); - harness.bazel_code = 3; - seed_npm_set(&harness, NPM_PACKAGE); - let (code, _, err) = harness.run(&["update", "web:left-pad", "1.3.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update_failed"), "{err}"); - assert!(read(&harness, "web/package.json").contains("1.3.0")); - assert_eq!(read(&harness, "web/pnpm-lock.yaml"), NPM_LOCK); - } - - const PAKET_DEPS: &str = - "source https://api.nuget.org/v3/index.json\nnuget Newtonsoft.Json 12.0.1\n"; - - #[test] - fn consumer_nuget_set_edits_paket_dependencies_only_and_names_the_follow_up() { - let harness = Harness::new("update-consumer-nuget"); - harness.write_source( - "MODULE.bazel", - "paket = use_extension(\"//dotnet/deps:paket.main_extension.bzl\", \"main_extension\")\n", - ); - let files = [ - ("dotnet/paket.lock", "NUGET\n"), - ("dotnet/deps/paket.main.bzl", "main\n"), - ]; - harness.write_source("dotnet/paket.dependencies", PAKET_DEPS); - for (rel, text) in files { - harness.write_source(rel, text); - } - let (code, _, err) = harness.run(&[ - "update", - "nuget:Newtonsoft.Json", - "13.0.3", - "--frozen", - "--apply", - ]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("frozen_locked"), "{err}"); - assert_unchanged(&harness, &[("dotnet/paket.dependencies", PAKET_DEPS)]); - let (code, out, err) = harness.run(&[ - "update", - "nuget:Newtonsoft.Json", - "13.0.3", - "--offline", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("run `paket update` in dotnet, then `dx update nuget`"), - "{out}" - ); - assert!(!out.contains("refreshed"), "{out}"); - assert_eq!( - read(&harness, "dotnet/paket.dependencies"), - PAKET_DEPS.replace("12.0.1", "13.0.3") - ); - assert_unchanged(&harness, &files); - assert!(harness.build_argv.borrow().is_empty()); - assert!(harness.seen_env.borrow().is_empty()); - } - - const GO_MOD: &str = - "module example.com/m\n\ngo 1.22\n\nrequire github.com/google/go-cmp v0.6.0\n"; - const GO_SUM: &str = "github.com/google/go-cmp v0.6.0 h1:abc=\n"; - const GO_FILES: [(&str, &str); 4] = [ - ( - "MODULE.bazel", - "bazel_dep(name = \"rules_go\", version = \"0.50.1\")\ngo_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//third_party/go:go.mod\")\n", - ), - ("third_party/go/go.mod", GO_MOD), - ("third_party/go/go.sum", GO_SUM), - ("tools/go.mod", GO_MOD), - ]; - - /// A runner that stands in for `go mod download all`: it records each launch and writes - /// the given files into the `-C` scratch directory, as go would. - struct GoRunner { - code: Option, - writes: Vec<(&'static str, String)>, - #[allow(clippy::type_complexity)] - launches: std::cell::RefCell, std::path::PathBuf, Vec<(String, String)>)>>, - scratch: std::cell::RefCell>, - } - - impl GoRunner { - fn new(code: Option, writes: &[(&'static str, &str)]) -> Self { - GoRunner { - code, - writes: writes - .iter() - .map(|(name, text)| (*name, (*text).to_owned())) - .collect(), - launches: Default::default(), - scratch: Default::default(), - } - } - } - - impl dx_process::Runner for GoRunner { - fn run( - &self, - argv: &[String], - cwd: &std::path::Path, - env: &[(&str, &str)], - ) -> std::io::Result { - self.launches.borrow_mut().push(( - argv.to_vec(), - cwd.to_path_buf(), - env.iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) - .collect(), - )); - let at = argv.iter().position(|arg| arg == "-C").expect("-C"); - let out = std::path::PathBuf::from(&argv[at + 1]); - for (name, text) in &self.writes { - std::fs::write(out.join(name), text)?; - } - *self.scratch.borrow_mut() = out.parent().map(std::path::Path::to_path_buf); - Ok(dx_process::ChildStatus { - code: self.code, - signal: None, - }) - } - } - - fn go_harness(name: &str) -> Harness { - let harness = Harness::new(name); - for (rel, text) in GO_FILES { - harness.write_source(rel, text); - } - harness - } - - fn run_go(harness: &Harness, runner: &GoRunner, words: &[&str]) -> (i32, String, String) { - let words: Vec = words.iter().map(|word| (*word).to_owned()).collect(); - let invocation = dx_plan::args::parse(&words).expect("parse"); - harness.execute_with(&invocation, runner) - } - - const GO_UPDATE: [&str; 5] = [ - "update", - "go:github.com/google/go-cmp", - "v0.7.0", - "--apply", - "--verbose", - ]; - - #[test] - fn consumer_go_set_widens_require_and_refreshes_go_sum_in_a_scratch_dir() { - let harness = go_harness("update-consumer-go"); - let runner = GoRunner::new( - Some(0), - &[ - ("go.sum", "new sums\n"), - ("go.mod", &GO_MOD.replace("v0.6.0", "v0.7.0")), - ], - ); - let (code, out, err) = run_go(&harness, &runner, &GO_UPDATE); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("refreshed go via `dx update go`"), "{out}"); - assert_eq!( - read(&harness, "third_party/go/go.mod"), - GO_MOD.replace("v0.6.0", "v0.7.0") - ); - assert_eq!(read(&harness, "third_party/go/go.sum"), "new sums\n"); - assert_unchanged(&harness, &[("tools/go.mod", GO_MOD), GO_FILES[0]]); - let launches = runner.launches.borrow(); - assert_eq!(launches.len(), 1, "{launches:?}"); - let (argv, cwd, env) = &launches[0]; - let at = argv.iter().position(|arg| arg == "-C").expect("-C"); - #[allow(clippy::disallowed_methods)] - let mut head = dx_process::startup_argv(&[]); - head.extend( - ["run", "--config=dx_build_dev", "@rules_go//go", "--", "-C"] - .iter() - .map(|word| (*word).to_owned()), - ); - assert_eq!(argv[..=at], head[..], "{argv:?}"); - assert_eq!(argv[at + 2..], ["mod", "download", "all"], "{argv:?}"); - let out_dir = std::path::PathBuf::from(&argv[at + 1]); - assert!(out_dir.ends_with("out")); - assert!(!out_dir.starts_with(&harness.workspace)); - assert_eq!(cwd, &harness.workspace); - let env: std::collections::BTreeMap<&str, &str> = env - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())) - .collect(); - assert_eq!(env["GOENV"], "off"); - assert_eq!(env["GOWORK"], "off"); - assert_eq!(env["GOTOOLCHAIN"], "local"); - let scratch = out_dir.parent().expect("scratch"); - for key in ["GOMODCACHE", "GOPATH", "GOCACHE"] { - assert!(std::path::Path::new(env[key]).starts_with(scratch), "{key}"); - } - assert!(!scratch.exists(), "scratch must be removed"); - } - - #[test] - fn consumer_go_set_json_reports_the_refresh() { - let harness = go_harness("update-consumer-go-json"); - let runner = GoRunner::new( - Some(0), - &[ - ("go.sum", "new sums\n"), - ("go.mod", &GO_MOD.replace("v0.6.0", "v0.7.0")), - ], - ); - let (code, out, err) = run_go( - &harness, - &runner, - &[ - "update", - "go:github.com/google/go-cmp", - "v0.7.0", - "--apply", - "--output=json", - ], - ); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let kinds = event_kinds(&events); - assert_eq!( - kinds, - ["command_started", "notice", "notice", "command_finished"], - "{out}" - ); - assert_eq!(read(&harness, "third_party/go/go.sum"), "new sums\n"); - } - - #[test] - fn consumer_go_set_refusals_write_nothing_and_launch_nothing() { - for (name, flag, want) in [ - ("frozen", "--frozen", "frozen_locked"), - ("offline", "--offline", "offline_required"), - ] { - let harness = go_harness(&format!("update-consumer-go-refuse-{name}")); - let runner = GoRunner::new(Some(0), &[("go.sum", "new sums\n")]); - let words = [ - "update", - "go:github.com/google/go-cmp", - "v0.7.0", - "--apply", - flag, - ]; - let (code, out, err) = run_go(&harness, &runner, &words); - assert_eq!(code, 1, "{name}: {out}{err}"); - assert!(format!("{out}{err}").contains(want), "{name}: {out}{err}"); - assert_unchanged(&harness, &GO_FILES); - assert!(runner.launches.borrow().is_empty(), "{name}"); - } - } - - #[test] - fn consumer_go_set_failed_refresh_keeps_the_widen_and_go_sum() { - for (name, code, writes, want) in [ - ( - "exit", - Some(1), - vec![("go.sum", "new sums\n")], - "updater exited 1", - ), - ( - "mod", - Some(0), - vec![("go.sum", "new sums\n"), ("go.mod", "module changed\n")], - "go changed go.mod", - ), - ] { - let harness = go_harness(&format!("update-consumer-go-fail-{name}")); - let runner = GoRunner::new(code, &writes); - let (status, out, err) = run_go(&harness, &runner, &GO_UPDATE); - assert_eq!(status, 1, "{name}: {out}{err}"); - assert!(err.contains(want), "{name}: {err}"); - assert!( - err.contains("widen kept in third_party/go/go.mod") - && err.contains("`go mod tidy` in third_party/go, then `dx update go`"), - "{name}: {err}" - ); - assert!(!format!("{out}{err}").contains("and refreshed"), "{name}"); - assert_eq!( - read(&harness, "third_party/go/go.mod"), - GO_MOD.replace("v0.6.0", "v0.7.0") - ); - assert_unchanged( - &harness, - &[("third_party/go/go.sum", GO_SUM), ("tools/go.mod", GO_MOD)], - ); - let scratch = runner.scratch.borrow().clone().expect("launched"); - assert!(!scratch.exists(), "{name}: scratch must be removed"); - } - } - - #[test] - fn consumer_go_set_json_reports_a_failed_refresh() { - let harness = go_harness("update-consumer-go-json-fail"); - let runner = GoRunner::new(Some(1), &[]); - let (code, out, _) = run_go( - &harness, - &runner, - &[ - "update", - "go:github.com/google/go-cmp", - "v0.7.0", - "--apply", - "--output=json", - ], - ); - assert_eq!(code, 1, "{out}"); - assert_eq!( - event_kinds(&json_events(&out)), - ["command_started", "notice", "error", "command_finished"], - "{out}" - ); - assert_unchanged(&harness, &[("third_party/go/go.sum", GO_SUM)]); - } - - #[test] - fn undeclared_set_selectors_exit_two_and_list_valid_names() { - let harness = Harness::new("update-consumer-builtin"); - harness.write_source("MODULE.bazel", CONSUMER_MODULE); - let (code, _, err) = harness.run(&["update", "cargo:anyhow", "1.2.3", "--apply"]); - assert_eq!(code, 2, "{err}"); - assert!( - err.contains("expected pypi-frontend, bazel, containers, github-actions"), - "{err}" - ); - } - - #[test] - fn unknown_set_without_module_bazel_writes_nothing() { - let harness = Harness::new("update-unknown-no-module"); - harness.write_source("package.json", "{}\n"); - let (code, _, err) = harness.run(&["update", "npm:react", "1.0.0", "--apply"]); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("unknown dependency set \"npm\""), "{err}"); - assert_eq!(read(&harness, "package.json"), "{}\n"); - } - - #[test] - fn unreadable_and_non_utf8_manifests_fail_without_refresh() { - for json in [false, true] { - for invalid_utf8 in [false, true] { - let harness = Harness::new("update-unreadable"); - if invalid_utf8 { - std::fs::write(harness.workspace.join(".bazelversion"), [0xff]) - .expect("invalid bytes"); - } - let output = if json { - "--output=json" - } else { - "--output=text" - }; - let (code, out, err) = - harness.run(&["update", "bazel:.bazelversion", "9.3.0", output, "--apply"]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains(if invalid_utf8 { - "not valid UTF-8" - } else { - "cannot read" - })); - assert!(harness.seen_env.borrow().is_empty()); - if json { - let events: Vec = out - .lines() - .map(|line| serde_json::from_str(line).expect("event")) - .collect(); - assert_eq!(events[1]["code"], super::CODE_UPDATE_WIDEN_FAILED); - assert_eq!(events.last().expect("finished")["exit_code"], 1); - } - } - } - } - - #[test] - fn frozen_widen_needed_fails_before_any_manifest_write() { - { - let (selector, version, path, before) = ( - "dx_crates:demo", - "1.2.3", - HELLO_CARGO, - "[dependencies]\ndemo = \"1\"\n", - ); - let harness = Harness::new("update-frozen-widen"); - seed(&harness, path, before); - let (code, out, err) = harness.run(&[ - "update", - selector, - version, - "--frozen", - "--output=json", - "--apply", - ]); - assert_eq!(code, 1, "{selector}: {out}{err}"); - assert!( - err.contains(&format!("dx: {}: ", super::CODE_FROZEN_LOCKED)), - "{selector}: {err}" - ); - assert!( - err.contains("no manifest changes performed"), - "{selector}: {err}" - ); - assert!( - !err.contains("offline_required"), - "{selector}: frozen stays independent: {err}" - ); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(path)).expect("manifest"), - before, - "{selector}: frozen must not widen" - ); - assert!( - harness.seen_env.borrow().is_empty(), - "{selector}: frozen launches nothing" - ); - let events = json_events(&out); - assert_eq!(event(&events, "error")["code"], super::CODE_FROZEN_LOCKED); - let finished = events.last().expect("finished"); - assert_eq!(finished["event"], "command_finished"); - assert_eq!(finished["exit_code"], 1); - assert_eq!(finished["results_complete"], false); - } - } - - #[test] - fn dry_run_rejects_bad_selector_and_version() { - let harness = Harness::new("update-dryrun-bad-selector"); - let (code, _, err) = harness.run(&["update", "crates", "1.2.3"]); - assert_eq!(code, 2, "{err}"); - let harness = Harness::new("update-dryrun-bad-version"); - let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "nope!!!"]); - assert_eq!(code, 2, "{err}"); - } - - #[test] - fn go_update_leaves_a_malformed_go_mod_unchanged() { - let harness = Harness::new("update-go-malformed"); - let before = "module example.com/root\nrequire (\n\texample.com/demo v1.2.0 // v1.0.0\n"; - harness.write_source("third_party/go/go.mod", before); - let (code, out, err) = harness.run(&[ - "update", - "go:example.com/demo", - "1.3.0", - "--offline", - "--apply", - ]); - assert_ne!(code, 0, "{out}{err}"); - assert_eq!( - std::fs::read_to_string(harness.workspace.join("third_party/go/go.mod")).expect("read"), - before - ); - } - - #[test] - fn live_bazel_file_only_has_no_refresh_launch() { - let harness = Harness::new("update-live-bazel-fileonly"); - harness.write_source(".bazelversion", "9.2.0\n"); - let (code, out, err) = harness.run(&["update", "bazel:.bazelversion", "9.3.0", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains("widened bazel:.bazelversion to 9.3.0"), - "{out}" - ); - assert!(out.contains("flag-diff"), "{out}"); - assert!(!out.contains("automatically"), "{out}"); - assert!(err.is_empty(), "{err}"); - assert!( - harness.seen_env.borrow().is_empty(), - "file-only chains nothing" - ); - } - - #[test] - fn live_bazel_module_edits_one_literal_version_atomically() { - let harness = Harness::new("update-live-bazel-module"); - let before = "module(name = 'demo')\n\n# bazel_dep(name = \"rules_cc\", version = \"0.0.1\")\nNOTE = \"bazel_dep(name = 'rules_cc', version = '0.0.2')\"\nbazel_dep(\n version = '0.2.21', # ) \" (\n name = 'rules_cc',\n repo_name = \"cc\",\n)\nbazel_dep(name = \"rules_rust\", version = V)\n"; - let after = before.replace("'0.2.21'", "'0.2.22'"); - harness.write_source("MODULE.bazel", before); - let read = - || std::fs::read_to_string(harness.workspace.join("MODULE.bazel")).expect("read"); - let (code, _, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("update drift"), "{err}"); - assert_eq!(read(), before, "check must not write"); - let (code, out, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("widened bazel:rules_cc to 0.2.22"), "{out}"); - assert_eq!(read(), after); - let (code, out, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(read(), after, "second run is a no-op"); - { - let (selector, reason) = ( - "bazel:rules_rust", - "bazel_dep \"rules_rust\" at line 10 has a computed version", - ); - let (code, _, err) = harness.run(&["update", selector, "1.0.0", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains(reason), "{err}"); - assert_eq!(read(), after, "{selector} must not write"); - } - harness.write_source( - "MODULE.bazel", - "bazel_dep(name = 'rules_cc', version = '0.2.21'\n", - ); - let (code, _, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); - assert_eq!(code, 2, "{err}"); - assert!(err.contains("MODULE.bazel does not parse at line"), "{err}"); - assert_eq!(read(), "bazel_dep(name = 'rules_cc', version = '0.2.21'\n"); - } - - const SPLIT_MODULE: &str = "# floor: equals tested\nbazel_dep(name = \"rules_cc\", version = \"0.2.21\")\nsingle_version_override(\n module_name = \"rules_cc\",\n version = \"0.2.21\",\n)\n"; - - #[test] - fn live_bazel_update_targets_the_tested_override_or_the_floor() { - let harness = Harness::new("update-live-bazel-floor"); - harness.write_source("MODULE.bazel", SPLIT_MODULE); - let read = - || std::fs::read_to_string(harness.workspace.join("MODULE.bazel")).expect("read"); - let (code, out, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - let tested = SPLIT_MODULE.replace(" version = \"0.2.21\",", " version = \"0.2.22\","); - assert_eq!(read(), tested); - let (code, out, err) = harness.run(&[ - "update", - "bazel:rules_cc", - "0.2.22", - "--floor", - "--reason", - "oldest passing the floor job", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!( - read(), - tested - .replace( - "# floor: equals tested", - "# floor: oldest passing the floor job" - ) - .replace("version = \"0.2.21\")", "version = \"0.2.22\")") - ); - } - - #[test] - fn floor_flags_and_order_refusals_are_usage_errors_that_write_nothing() { - let harness = Harness::new("update-floor-refusals"); - harness.write_source("MODULE.bazel", SPLIT_MODULE); - let read = - || std::fs::read_to_string(harness.workspace.join("MODULE.bazel")).expect("read"); - for (args, needle) in [ - ( - vec!["update", "bazel:rules_cc", "0.2.20", "--floor", "--apply"], - "--floor needs --reason", - ), - ( - vec![ - "update", - "bazel:rules_cc", - "0.2.20", - "--reason", - "x", - "--apply", - ], - "--reason only applies with --floor", - ), - ( - vec![ - "update", - "github-actions:actions/checkout", - GHA_SHA, - "--floor", - "--reason", - "x", - ], - "--floor applies only to a MODULE.bazel bazel_dep", - ), - ( - vec![ - "update", - "bazel:rules_cc", - "0.3.0", - "--floor", - "--reason", - "x", - "--apply", - ], - "above its tested version", - ), - ( - vec!["update", "bazel:rules_cc", "0.2.0", "--apply"], - "below its floor", - ), - ] { - let (code, _, err) = harness.run(&args); - assert_eq!(code, 2, "{args:?}: {err}"); - assert!(err.contains(needle), "{args:?}: {err}"); - assert_eq!(read(), SPLIT_MODULE, "{args:?} must not write"); - } - } - - fn tag_request() -> dx_update::edit::UpdateRequest { - dx_update::edit::UpdateRequest::parse("github-actions:actions/checkout", "v5") - .expect("request") - } - - #[test] - fn gha_tag_is_resolved_through_the_fetch() { - let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let seen = std::cell::RefCell::new(Vec::new()); - let fetch = |url: &str| { - seen.borrow_mut().push(url.to_owned()); - Ok(format!("{{\"sha\": \"{sha}\"}}")) - }; - let resolved = - resolve_gha_tag_request(&tag_request(), None, false, &fetch).expect("resolved"); - assert_eq!( - *seen.borrow(), - ["https://api.github.com/repos/actions/checkout/commits/v5"] - ); - let text = "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4\n"; - let widened = resolved.plan_edit(text).expect("edit"); - assert!(widened.ends_with(&format!("@{sha} # v5\n")), "{widened}"); - } - - #[test] - fn gha_tag_fetch_error_and_offline_resolve_nothing() { - let failing = |_: &str| Err("HTTP 404".to_owned()); - let (code, message) = - resolve_gha_tag_request(&tag_request(), None, false, &failing).expect_err("error"); - assert_eq!(code, CODE_UPDATE_WIDEN_FAILED); - assert!(message.contains("HTTP 404"), "{message}"); - let calls = std::cell::Cell::new(0); - let counting = |_: &str| { - calls.set(calls.get() + 1); - Err("unused".to_owned()) - }; - let (code, _) = - resolve_gha_tag_request(&tag_request(), None, true, &counting).expect_err("error"); - assert_eq!(code, CODE_OFFLINE_REQUIRED); - assert_eq!(calls.get(), 0); - } - - #[test] - fn live_github_tag_resolution_matrix() { - let harness = Harness::new("update-live-gha-tag"); - harness.write_source( - ".github/workflows/ci.yml", - "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n", - ); - let (code, _, err) = harness.run(&[ - "update", - "github-actions:actions/checkout", - "v5", - "--offline", - "--apply", - ]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("offline_required"), "{err}"); - - let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let commit_json = format!("{{\"sha\": \"{sha}\", \"commit\": {{\"message\": \"v5\"}}}}"); - let mut harness = Harness::new("update-live-gha-resolved"); - harness.write_source( - ".github/workflows/ci.yml", - "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n - uses: actions/cache@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v6\n", - ); - harness.write_source( - ".github/workflows/reusable.yaml", - "jobs:\n b:\n steps:\n - uses: actions/checkout@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v6\n", - ); - harness.write_source("commit.json", &commit_json); - harness.edit_commit_json = - Some(harness.workspace.join("commit.json").display().to_string()); - let (code, out, err) = harness.run(&[ - "update", - "github-actions:actions/checkout", - "v5", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(err, "", "{err}"); - let widened = std::fs::read_to_string(harness.workspace.join(".github/workflows/ci.yml")) - .expect("workflow"); - assert!( - widened.contains(&format!("actions/checkout@{sha}")), - "{widened}" - ); - assert!(widened.contains("actions/cache@bbbb"), "{widened}"); - assert!( - widened.contains(&format!("actions/checkout@{sha} # v5")), - "{widened}" - ); - let widened_yaml = - std::fs::read_to_string(harness.workspace.join(".github/workflows/reusable.yaml")) - .expect("workflow"); - assert!( - widened_yaml.contains(&format!("actions/checkout@{sha}")), - "{widened_yaml}" - ); - let events = json_events(&out); - assert!( - events.iter().any(|event| event["code"] == "update_widened"), - "{out}" - ); - assert_eq!(events.last().expect("finished")["exit_code"], 0); - - let mut harness = Harness::new("update-live-gha-nochange"); - harness.write_source( - ".github/workflows/ci.yml", - &format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{sha} # v5\n"), - ); - harness.write_source("commit.json", &commit_json); - harness.edit_commit_json = - Some(harness.workspace.join("commit.json").display().to_string()); - let (code, out, err) = harness.run(&[ - "update", - "github-actions:actions/checkout", - "v5", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - assert_eq!(err, "", "{err}"); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(".github/workflows/ci.yml")) - .expect("workflow"), - format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{sha} # v5\n") - ); - let events = json_events(&out); - assert!( - events - .iter() - .any(|event| event["code"] == "update_no_change"), - "{out}" - ); - assert_eq!(events.last().expect("finished")["exit_code"], 0); - - let (code, out, err) = - harness.run(&["update", "github-actions:actions/checkout", "v5", "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!(out.contains("no-change"), "{out}"); - assert!(err.is_empty(), "{err}"); - - for (name, body) in [ - ("missing-file", None), - ("invalid-json", Some("not json")), - ("upstream-error", Some("{\"message\": \"Not Found\"}")), - ("missing-sha", Some("{\"commit\": {}}")), - ("invalid-sha", Some("{\"sha\": \"v5\"}")), - ] { - let mut harness = Harness::new(&format!("update-live-gha-fail-{name}")); - harness.write_source( - ".github/workflows/ci.yml", - "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n", - ); - match body { - Some(body) => { - harness.write_source("commit.json", body); - harness.edit_commit_json = - Some(harness.workspace.join("commit.json").display().to_string()); - } - None => { - harness.edit_commit_json = - Some(harness.workspace.join("absent.json").display().to_string()); - } - } - let (code, out, err) = - harness.run(&["update", "github-actions:actions/checkout", "v5", "--apply"]); - assert_eq!(code, 1, "{name}: {out}{err}"); - assert!(err.contains("update_widen_failed"), "{name}: {err}"); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(".github/workflows/ci.yml")) - .expect("workflow"), - "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n", - "{name}: failed lookup must not widen" - ); - } - } - - #[test] - fn live_github_actions_widens_every_workflow_that_pins_the_action() { - let harness = Harness::new("update-live-gha-every"); - let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - for name in ["ci.yml", "reusable.yaml"] { - harness.write_source( - &format!(".github/workflows/{name}"), - "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n - uses: actions/cache@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v6\n", - ); - } - harness.write_source( - ".github/workflows/update.yml", - "jobs:\n b:\n steps:\n - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # v0.19.0\n", - ); - harness.write_source(".github/workflows/README.md", "not a workflow\n"); - let (code, out, err) = - harness.run(&["update", "github-actions:actions/checkout", sha, "--apply"]); - assert_eq!(code, 0, "{out}{err}"); - assert!(err.is_empty(), "{err}"); - assert!( - out.contains( - "widened github-actions:actions/checkout to aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa in \ - .github/workflows/ci.yml, .github/workflows/reusable.yaml" - ), - "{out}" - ); - for name in ["ci.yml", "reusable.yaml"] { - let widened = std::fs::read_to_string( - harness.workspace.join(format!(".github/workflows/{name}")), - ) - .expect("workflow"); - assert!( - widened.contains(&format!("actions/checkout@{sha} # v7")), - "{widened}" - ); - assert!(widened.contains("actions/cache@bbbb"), "{widened}"); - } - let untouched = - std::fs::read_to_string(harness.workspace.join(".github/workflows/update.yml")) - .expect("workflow"); - assert!(untouched.contains("setup-bazel"), "{untouched}"); - } - - #[test] - fn live_github_actions_fails_closed_and_writes_no_workflow() { - let sha = "cccccccccccccccccccccccccccccccccccccccc"; - let cases = [ - ( - "ambiguous", - vec![( - "ci.yml", - vec![ - "jobs:\n b:\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n", - " - uses: actions/checkout@2222222222222222222222222222222222222222\n", - ], - )], - "ambiguous requirement", - ), - ( - "unpinned", - vec![( - "ci.yml", - vec![ - "jobs:\n b:\n steps:\n - uses: bazel-contrib/setup-bazel@cccccccccccccccccccccccccccccccccccccccc # v0.19.0\n", - ], - )], - "no declared requirement", - ), - ( - "malformed", - vec![ - ( - "a.yml", - vec![ - "jobs:\n b:\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n", - ], - ), - ("b.yml", vec!["jobs: [\n"]), - ], - "not valid YAML", - ), - ( - "aliased", - vec![ - ( - "a.yml", - vec![ - "jobs:\n b:\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n", - ], - ), - ( - "b.yml", - vec!["x: &s\n - uses: actions/checkout@v1\njobs:\n b:\n steps: *s\n"], - ), - ], - "aliases or merge keys", - ), - ( - "no-workflows", - vec![("README.md", vec!["not a workflow\n"])], - "no declared requirement", - ), - ]; - for (name, workflows, expected) in cases { - let harness = Harness::new(&format!("update-live-gha-{name}")); - for (file, lines) in &workflows { - harness.write_source(&format!(".github/workflows/{file}"), &lines.concat()); - } - let (code, _, err) = - harness.run(&["update", "github-actions:actions/checkout", sha, "--apply"]); - assert_eq!(code, 1, "{name}: {err}"); - assert!(err.contains("update_widen_failed"), "{name}: {err}"); - assert!(err.contains(expected), "{name}: {err}"); - assert!(err.contains(".github/workflows"), "{name}: {err}"); - for (file, lines) in &workflows { - let text = std::fs::read_to_string( - harness.workspace.join(format!(".github/workflows/{file}")), - ) - .expect("workflow"); - assert_eq!(&text, &lines.concat(), "{name} must widen nothing"); - } - } - let missing = Harness::new("update-live-gha-missing-dir"); - missing.write_source(".bazelversion", "9.2.0\n"); - let (code, _, err) = - missing.run(&["update", "github-actions:actions/checkout", sha, "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!(err.contains("cannot list .github/workflows"), "{err}"); - } - - #[test] - fn live_json_emits_planned_or_widened_and_finished() { - let harness = Harness::new("update-live-json"); - harness.write_source(".bazelversion", "9.2.0\n"); - let (code, out, err) = harness.run(&[ - "update", - "bazel:.bazelversion", - "9.3.0", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - let events = json_events(&out); - let kinds = event_kinds(&events); - assert_eq!(kinds[0], "command_started"); - assert_eq!(kinds[kinds.len() - 1], "command_finished"); - assert!(kinds.contains(&"notice")); - assert_eq!( - events.last().expect("finished")["exit_code"], - serde_json::json!(0) - ); - assert_eq!(err, "", "{err}"); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(".bazelversion")).expect("read"), - "9.3.0\n" - ); - } - - const GHA_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - /// One already-current declaration per set: selector, version, manifest, text. - fn current_pins() -> Vec<(&'static str, &'static str, &'static str, String)> { - vec![ - ( - "bazel:rules_cc", - "0.2.22", - "MODULE.bazel", - "bazel_dep(name = \"rules_cc\", version = \"0.2.22\")\n".to_owned(), - ), - ( - "dx_crates:anyhow", - "1.2.3", - HELLO_CARGO, - "[dependencies]\nanyhow = \"1.2.3\"\n".to_owned(), - ), - ( - "github-actions:actions/checkout", - GHA_SHA, - ".github/workflows/ci.yml", - format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{GHA_SHA} # v5\n"), - ), - ] - } - - #[test] - fn unchanged_version_reports_no_change_for_every_set() { - for (selector, version, path, text) in current_pins() { - let harness = Harness::new("update-no-change-every-set"); - seed(&harness, path, &text); - let (code, out, err) = harness.run(&["update", selector, version, "--apply"]); - assert_eq!(code, 0, "{selector}: {out}{err}"); - assert!(out.contains("no-change"), "{selector}: {out}"); - assert!(err.is_empty(), "{selector}: {err}"); - let (code, out, err) = - harness.run(&["update", selector, version, "--output=json", "--apply"]); - assert_eq!(code, 0, "{selector}: {out}{err}"); - let events = json_events(&out); - assert_eq!( - event(&events, "notice")["code"], - "update_no_change", - "{out}" - ); - assert_eq!(events.last().expect("finished")["exit_code"], 0); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(path)).expect("manifest"), - text, - "{selector}: no-change writes nothing" - ); - assert!( - harness.seen_env.borrow().is_empty(), - "{selector}: no-change refreshes nothing" - ); - } - } - - #[test] - fn unchanged_version_check_reports_no_change_like_apply() { - for (selector, version, path, text) in current_pins() { - let harness = Harness::new("update-no-change-check"); - seed(&harness, path, &text); - let (code, out, err) = harness.run(&["update", selector, version]); - assert_eq!(code, 0, "{selector}: {out}{err}"); - assert!(out.contains("no-change"), "{selector}: {out}"); - assert!(err.is_empty(), "{selector}: {err}"); - let (code, out, err) = harness.run(&["update", selector, version, "--output=json"]); - assert_eq!(code, 0, "{selector}: {out}{err}"); - let events = json_events(&out); - assert_eq!( - event(&events, "notice")["code"], - "update_no_change", - "{out}" - ); - assert_eq!(events.last().expect("finished")["exit_code"], 0); - assert!( - harness.seen_env.borrow().is_empty(), - "{selector}: check refreshes nothing" - ); - } - } - - #[test] - fn unchanged_version_under_frozen_and_offline_exits_zero() { - for flag in ["--frozen", "--offline"] { - for (selector, version, path, text) in current_pins() { - let harness = Harness::new("update-no-change-gated"); - seed(&harness, path, &text); - let (code, out, err) = harness.run(&[ - "update", - selector, - version, - flag, - "--output=json", - "--apply", - ]); - assert_eq!(code, 0, "{selector} {flag}: {out}{err}"); - let events = json_events(&out); - assert_eq!( - event(&events, "notice")["code"], - "update_no_change", - "{out}" - ); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(path)).expect("manifest"), - text - ); - assert!(harness.seen_env.borrow().is_empty(), "{selector} {flag}"); - } - } - } - - #[test] - fn github_actions_writes_only_changed_workflows() { - let harness = Harness::new("update-gha-only-changed"); - let pinned = - format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{GHA_SHA} # v5\n"); - harness.write_source( - ".github/workflows/a.yml", - "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4\n", - ); - harness.write_source(".github/workflows/b.yml", &pinned); - let (code, out, err) = harness.run(&[ - "update", - "github-actions:actions/checkout", - GHA_SHA, - "--apply", - ]); - assert_eq!(code, 0, "{out}{err}"); - assert!( - out.contains(&format!( - "widened github-actions:actions/checkout to {GHA_SHA} in .github/workflows/a.yml" - )), - "{out}" - ); - assert!(!out.contains("b.yml"), "{out}"); - assert!( - std::fs::read_to_string(harness.workspace.join(".github/workflows/a.yml")) - .expect("workflow") - .contains(GHA_SHA) - ); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(".github/workflows/b.yml")) - .expect("workflow"), - pinned - ); - } - - const CARGO_TREE: [(&str, &str); 3] = [ - ("cli/a/Cargo.toml", "[dependencies]\nanyhow = \"1\"\n"), - ("cli/b/Cargo.toml", "[dependencies]\nserde = \"1\"\n"), - ( - "rust/tests/fixtures/hello/Cargo.toml", - "[dev-dependencies]\nanyhow = { version = \"1.0\" }\n", - ), - ]; - - fn read(harness: &Harness, path: &str) -> String { - std::fs::read_to_string(harness.workspace.join(path)).expect("manifest") - } - - #[test] - fn cargo_check_mode_reports_drift_across_manifests() { - let harness = Harness::new("update-cargo-check-manifests"); - seed_cargo(&harness, &CARGO_TREE); - let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "1.2.3"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains( - "needs widening to 1.2.3 in rust/tests/fixtures/hello/Cargo.toml, cli/a/Cargo.toml" - ), - "{err}" - ); - for (path, text) in CARGO_TREE { - assert_eq!(read(&harness, path), text, "check must not write {path}"); - } - assert!(harness.seen_env.borrow().is_empty()); - } - - #[test] - fn cargo_without_a_declared_hub_is_an_unknown_set() { - let harness = Harness::new("update-cargo-no-module"); - harness.write_source(HELLO_CARGO, "[dependencies]\nanyhow = \"1\"\n"); - let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "1.2.3", "--apply"]); - assert_eq!(code, 2, "{err}"); - assert!( - err.contains("unknown dependency set \"dx_crates\""), - "{err}" - ); - assert_eq!( - read(&harness, HELLO_CARGO), - "[dependencies]\nanyhow = \"1\"\n" - ); - assert!(harness.seen_env.borrow().is_empty()); - } - - #[test] - fn cargo_path_dependency_in_one_manifest_fails_closed_writing_nothing() { - let harness = Harness::new("update-cargo-path-dep"); - let tree = [ - CARGO_TREE[0], - ( - "cli/b/Cargo.toml", - "[dependencies]\nanyhow = { path = \"../anyhow\" }\n", - ), - ]; - seed_cargo(&harness, &tree); - let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "1.2.3", "--apply"]); - assert_eq!(code, 1, "{err}"); - assert!( - err.contains("unsupported manifest shape in cli/b/Cargo.toml"), - "{err}" - ); - for (path, text) in tree { - assert_eq!(read(&harness, path), text, "{path}"); - } - assert!(harness.seen_env.borrow().is_empty()); - } - - #[test] - fn default_check_json_reports_drift_without_writing() { - let harness = Harness::new("update-default-json"); - harness.write_source(".bazelversion", "9.2.0\n"); - let (code, out, err) = - harness.run(&["update", "bazel:.bazelversion", "9.3.0", "--output=json"]); - assert_eq!(code, 1, "{out}{err}"); - assert!(err.contains("update_widen_failed"), "{err}"); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(".bazelversion")).expect("read"), - "9.2.0\n", - "check must not write" - ); - let events = json_events(&out); - assert_eq!( - event(&events, "error")["code"], - super::CODE_UPDATE_WIDEN_FAILED - ); - assert_eq!( - events.last().expect("finished")["exit_code"], - serde_json::json!(1) - ); - let (code, _, _) = harness.run(&[ - "update", - "bazel:.bazelversion", - "9.3.0", - "--output=json", - "--apply", - ]); - assert_eq!(code, 0); - assert_eq!( - std::fs::read_to_string(harness.workspace.join(".bazelversion")).expect("read"), - "9.3.0\n" - ); - } -} +#[path = "update_version_tests.rs"] +mod tests; diff --git a/cli/cli/src/exec/update_version_tests.rs b/cli/cli/src/exec/update_version_tests.rs new file mode 100644 index 0000000000..3dcf103f2a --- /dev/null +++ b/cli/cli/src/exec/update_version_tests.rs @@ -0,0 +1,1849 @@ +use super::super::test_support::*; +use super::*; + +const HELLO_CARGO: &str = "rust/tests/fixtures/hello/Cargo.toml"; + +/// A root MODULE.bazel whose dx_crates hub reads the given Cargo.toml labels. +fn cargo_module(labels: &[&str]) -> String { + let manifests = labels + .iter() + .map(|label| format!(" \"{label}\",\n")) + .collect::(); + format!( + "bazel_dep(name = \"rules_rust\", version = \"0.74.0\")\n\ncrate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(\n name = \"dx_crates\",\n cargo_lockfile = \"//rust/tests/fixtures/hello:Cargo.lock\",\n manifests = [\n{manifests} ],\n)\n" + ) +} + +/// Writes the cargo manifests and a MODULE.bazel hub that lists all of them. +fn seed_cargo(harness: &Harness, manifests: &[(&str, &str)]) { + let labels: Vec = manifests + .iter() + .map(|(path, _)| { + let (package, file) = path.rsplit_once('/').expect("package path"); + format!("//{package}:{file}") + }) + .collect(); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + harness.write_source("MODULE.bazel", &cargo_module(&labels)); + for (path, text) in manifests { + harness.write_source(path, text); + } +} + +/// Writes one manifest; a Cargo.toml also gets the MODULE.bazel hub that lists it. +fn seed(harness: &Harness, path: &str, text: &str) { + if path.ends_with("Cargo.toml") { + seed_cargo(harness, &[(path, text)]); + } else { + harness.write_source(path, text); + } +} + +const CONSUMER_MODULE: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\nuv.project(hub_name = \"pypi\", pyproject = \"//apps/frontend:pyproject.toml\", lock = \"//apps/frontend:uv.lock\")\n"; + +const CONSUMER_PYPROJECT: &str = r#" +[project] +name = "frontend" +version = "0.1.0" +requires-python = ">=3.9" +dependencies = ["anyio>=4"] +"#; + +#[test] +fn consumer_configured_uv_set_needs_a_package_and_widens_pyproject() { + let harness = Harness::new("update-consumer"); + harness.write_source("MODULE.bazel", CONSUMER_MODULE); + harness.write_source("apps/frontend/pyproject.toml", CONSUMER_PYPROJECT); + let (code, _, err) = harness.run(&["update", "pypi-frontend", "4.1.0", "--apply"]); + assert_ne!(code, 0, "{err}"); + assert!( + err.contains("update needs one package, not a whole set"), + "{err}" + ); + assert_eq!( + std::fs::read(harness.workspace.join("apps/frontend/pyproject.toml")).expect("manifest"), + CONSUMER_PYPROJECT.as_bytes() + ); + assert!(harness.seen_env.borrow().is_empty()); + // The refresh cannot run offline, but the widen is refused before any write. + let (code, _, err) = harness.run(&[ + "update", + "pypi-frontend:anyio", + "4.1.0", + "--apply", + "--offline", + ]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("without network"), "{err}"); + assert_eq!( + std::fs::read(harness.workspace.join("apps/frontend/pyproject.toml")).expect("manifest"), + CONSUMER_PYPROJECT.as_bytes() + ); +} + +const PINNED_MODULE: &str = "ruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(name = \"gems\", gemfile = \"//apps/rb:Gemfile\", gemfile_lock = \"//apps/rb:Gemfile.lock\")\n"; + +#[test] +fn consumer_ruby_set_refuses_an_unversioned_gem_without_mutation() { + let harness = Harness::new("update-consumer-pinned"); + harness.write_source("MODULE.bazel", PINNED_MODULE); + let files = [ + ( + "apps/rb/Gemfile", + "source \"https://rubygems.org\"\ngem \"rack\"\n", + ), + ("apps/rb/Gemfile.lock", "GEM\n specs:\n rack (3.0.0)\n"), + ]; + for (rel, text) in files { + harness.write_source(rel, text); + } + let (code, _, err) = harness.run(&["update", "gems:rack", "1.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("the gem line needs exactly one literal version requirement"), + "{err}" + ); + for (rel, text) in files { + assert_eq!( + std::fs::read(harness.workspace.join(rel)).expect("unchanged"), + text.as_bytes(), + "{rel}" + ); + } + assert!(harness.seen_env.borrow().is_empty()); +} + +const COMPOSER_MODULE: &str = "composer = use_extension(\"@rules_dx//php/composer:extensions.bzl\", \"composer\")\ncomposer.lock(name = \"vendor\", digests = \"//apps/php:composer.digests.json\", lock = \"//apps/php:composer.lock\")\n"; +const COMPOSER_JSON: &str = "{\n \"require\": {\n \"psr/log\": \"^1.1\"\n }\n}\n"; + +fn seed_composer_set(harness: &Harness) { + harness.write_source("MODULE.bazel", COMPOSER_MODULE); + harness.write_source("apps/php/composer.json", COMPOSER_JSON); + harness.write_source("apps/php/composer.lock", "{\"packages\":[]}\n"); + harness.write_source("apps/php/composer.digests.json", "{\"packages\":{}}\n"); +} + +#[test] +fn consumer_composer_set_refuses_an_undeclared_package_without_mutation() { + let harness = Harness::new("update-consumer-composer-undeclared"); + seed_composer_set(&harness); + let (code, _, err) = harness.run(&["update", "vendor:acme/other", "1.0.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert_unchanged(&harness, &[("apps/php/composer.json", COMPOSER_JSON)]); + assert!(harness.seen_env.borrow().is_empty()); +} + +#[test] +fn consumer_composer_set_widens_the_constraint_and_refreshes() { + let harness = Harness::new("update-consumer-composer"); + seed_composer_set(&harness); + let (code, out, err) = harness.run(&["update", "vendor:psr/log", "3.0.2", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read(&harness, "apps/php/composer.json"), + COMPOSER_JSON.replace("^1.1", "^3.0.2") + ); + assert!(!harness.build_argv.borrow().is_empty()); +} + +const CPAN_MODULE: &str = "cpan = use_extension(\"@rules_perl//perl/cpan:extensions.bzl\", \"cpan\")\ncpan.install(name = \"cpan_deps\", lock = \"//pl:cpanfile.snapshot.lock.json\")\n"; +const CPANFILE: &str = "requires 'Text::CSV', '== 2.06';\n"; + +fn seed_cpan_set(harness: &Harness) { + harness.write_source("MODULE.bazel", CPAN_MODULE); + harness.write_source("pl/cpanfile", CPANFILE); + harness.write_source( + "pl/cpanfile.snapshot", + "# carton snapshot format: version 1.0\nDISTRIBUTIONS\n Text-CSV-2.06\n", + ); + harness.write_source( + "pl/cpanfile.snapshot.lock.json", + "{\"Text-CSV\":{\"strip_prefix\":\"Text-CSV-2.06\"}}\n", + ); + harness.write_source( + "pl/BUILD.bazel", + "cpan_compiler(name = \"compiler\", cpanfile = \"cpanfile\", lockfile = \"cpanfile.snapshot.lock.json\")\n", + ); +} + +#[test] +fn consumer_cpan_set_widens_the_cpanfile_and_refreshes() { + let harness = Harness::new("update-consumer-cpan"); + seed_cpan_set(&harness); + let (code, out, err) = harness.run(&["update", "cpan_deps:Text::CSV", "2.07", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read(&harness, "pl/cpanfile"), + CPANFILE.replace("2.06", "2.07") + ); + let runs = harness.build_argv.borrow(); + assert_eq!(runs.len(), 2, "{runs:?}"); + assert!( + runs[0] + .iter() + .any(|arg| arg == "@rules_dx//perl/tools:carton"), + "{:?}", + runs[0] + ); + assert!( + runs[1].iter().any(|arg| arg == "//pl:compiler"), + "{:?}", + runs[1] + ); +} + +#[test] +fn consumer_cpan_set_refusals_write_nothing_and_launch_nothing() { + let harness = Harness::new("update-consumer-cpan-refuse"); + seed_cpan_set(&harness); + let (code, _, err) = harness.run(&["update", "cpan_deps:Other::Mod", "1.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + let (code, _, err) = harness.run(&[ + "update", + "cpan_deps:Text::CSV", + "2.07", + "--apply", + "--frozen", + ]); + assert_eq!(code, 1, "{err}"); + assert_unchanged(&harness, &[("pl/cpanfile", CPANFILE)]); + assert!(harness.build_argv.borrow().is_empty()); +} + +const CARGO_A: &str = + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n"; +const CARGO_B: &str = + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\nanyhow = \"1\"\n"; + +const CARGO_SET_MODULE: &str = "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//rs/a:Cargo.lock\", manifests = [\"//rs/a:Cargo.toml\"])\ncrate.from_cargo(name = \"tools\", cargo_lockfile = \"//rs/b:Cargo.lock\", manifests = [\"//rs/b:Cargo.toml\"])\nuse_repo(crate, \"crates\", \"tools\")\n"; + +fn seed_cargo_set(harness: &Harness) { + harness.write_source("MODULE.bazel", CARGO_SET_MODULE); + harness.write_source("rs/a/Cargo.toml", CARGO_A); + harness.write_source("rs/b/Cargo.toml", CARGO_B); + harness.write_source("rs/a/Cargo.lock", "version = 4\n"); + harness.write_source("rs/b/Cargo.lock", "version = 4\n"); +} + +fn assert_unchanged(harness: &Harness, files: &[(&str, &str)]) { + for (rel, text) in files { + assert_eq!( + std::fs::read(harness.workspace.join(rel)).expect("unchanged"), + text.as_bytes(), + "{rel}" + ); + } +} + +#[test] +fn consumer_cargo_set_widens_its_manifest_only_and_repins_the_workspace() { + let harness = Harness::new("update-consumer-cargo"); + seed_cargo_set(&harness); + let (code, out, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("widened crates:serde to 2.0.0 in rs/a/Cargo.toml"), + "{out}" + ); + assert!( + out.contains("refreshed crates via `dx update crates`"), + "{out}" + ); + assert_eq!( + read(&harness, "rs/a/Cargo.toml"), + CARGO_A.replace("\"1\"", "\"2.0.0\"") + ); + assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); + let runs = harness.build_argv.borrow(); + assert_eq!(runs.len(), 1, "{runs:?}"); + assert!(runs[0].iter().any(|arg| arg == "fetch"), "{:?}", runs[0]); + assert_eq!(runs[0].last().map(String::as_str), Some("--repo=@crates")); + assert_eq!( + *harness.seen_env.borrow(), + vec![dx_update::backend::cargo_env("workspace", "crates")] + ); + assert_eq!(*harness.seen_cwd.borrow(), vec![harness.workspace.clone()]); +} + +#[test] +fn consumer_cargo_set_over_two_directories_widens_every_declaring_manifest_and_repins_once() { + let harness = Harness::new("update-consumer-cargo-multi"); + seed_cargo_set(&harness); + harness.write_source("rs/c/Cargo.toml", CARGO_A); + harness.write_source( + "MODULE.bazel", + &CARGO_SET_MODULE.replace( + "[\"//rs/a:Cargo.toml\"]", + "[\"//rs/a:Cargo.toml\", \"//rs/c:Cargo.toml\"]", + ), + ); + let (code, out, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + let widened = CARGO_A.replace("\"1\"", "\"2.0.0\""); + assert_eq!(read(&harness, "rs/a/Cargo.toml"), widened); + assert_eq!(read(&harness, "rs/c/Cargo.toml"), widened); + assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); + assert_eq!(harness.build_argv.borrow().len(), 1); +} + +#[test] +fn consumer_cargo_set_missing_requirement_is_added_to_the_first_manifest_and_repins() { + let harness = Harness::new("update-consumer-cargo-missing"); + seed_cargo_set(&harness); + let (code, out, err) = harness.run(&["update", "crates:anyhow", "2.0.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("added crates:anyhow at 2.0.0 to rs/a/Cargo.toml"), + "{out}" + ); + assert!(read(&harness, "rs/a/Cargo.toml").contains("anyhow = \"2.0.0\"")); + assert_unchanged(&harness, &[("rs/b/Cargo.toml", CARGO_B)]); + assert_eq!(harness.build_argv.borrow().len(), 1); +} + +#[test] +fn consumer_cargo_set_widens_only_the_manifests_that_declare_the_crate() { + let harness = Harness::new("update-consumer-cargo-partial"); + seed_cargo_set(&harness); + harness.write_source("rs/c/Cargo.toml", CARGO_B); + harness.write_source( + "MODULE.bazel", + &CARGO_SET_MODULE.replace( + "[\"//rs/a:Cargo.toml\"]", + "[\"//rs/a:Cargo.toml\", \"//rs/c:Cargo.toml\"]", + ), + ); + let (code, out, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read(&harness, "rs/a/Cargo.toml"), + CARGO_A.replace("\"1\"", "\"2.0.0\"") + ); + assert_unchanged( + &harness, + &[("rs/b/Cargo.toml", CARGO_B), ("rs/c/Cargo.toml", CARGO_B)], + ); + assert_eq!(harness.build_argv.borrow().len(), 1); +} + +#[test] +fn consumer_cargo_set_refusals_write_nothing_and_launch_nothing() { + let harness = Harness::new("update-consumer-cargo-refuse"); + seed_cargo_set(&harness); + let files = [ + ("rs/a/Cargo.toml", CARGO_A), + ("rs/b/Cargo.toml", CARGO_B), + ("rs/a/Cargo.lock", "version = 4\n"), + ("rs/b/Cargo.lock", "version = 4\n"), + ]; + let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--offline", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--frozen", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + let (code, _, err) = harness.run(&["update", "crates", "2.0.0", "--apply"]); + assert_eq!(code, 2, "{err}"); + let (code, _, err) = harness.run(&[ + "update", + "crates:serde", + "2.0.0", + "--floor", + "--reason", + "x", + "--apply", + ]); + assert_ne!(code, 0, "{err}"); + assert_unchanged(&harness, &files); + harness.write_source( + "MODULE.bazel", + &CARGO_SET_MODULE.replace("//rs/a:Cargo.lock", "//rs/a:other.lock"), + ); + let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("no widen performed"), "{err}"); + assert_unchanged(&harness, &files); + assert!(harness.build_argv.borrow().is_empty()); + assert!(harness.seen_env.borrow().is_empty()); +} + +#[test] +fn consumer_cargo_set_offline_json_fails_before_widening() { + let harness = Harness::new("update-consumer-cargo-offline-json"); + seed_cargo_set(&harness); + let (code, out, err) = harness.run(&[ + "update", + "crates:serde", + "2.0.0", + "--offline", + "--output=json", + "--apply", + ]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("no widen performed"), "{err}"); + assert_eq!(read(&harness, "rs/a/Cargo.toml"), CARGO_A); + assert!(harness.seen_env.borrow().is_empty()); + let events = json_events(&out); + let errors = events_of_kind(&events, "error"); + assert_eq!(errors[0]["code"], serde_json::json!("offline_required")); + let finished = events.last().expect("finished"); + assert_eq!(finished["exit_code"], 1); + assert_eq!(finished["results_complete"], false); +} + +#[test] +fn file_only_updates_work_offline_with_json_reports() { + for (selector, version, path, before, after) in [ + ( + "bazel:.bazelversion", + "9.3.0", + ".bazelversion", + "9.2.0\n", + "9.3.0\n", + ), + ( + "github-actions:actions/checkout", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ".github/workflows/ci.yml", + "jobs:\n b:\n steps:\n - uses: actions/checkout@v1\n", + "jobs:\n b:\n steps:\n - uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + ), + ] { + let harness = Harness::new("update-file-offline"); + harness.write_source(path, before); + let (code, out, err) = harness.run(&[ + "update", + selector, + version, + "--offline", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{selector}: {out}{err}"); + assert_eq!(read(&harness, path), after); + assert!(harness.seen_env.borrow().is_empty()); + let events = json_events(&out); + let notices = events_of_kind(&events, "notice"); + assert!( + notices.iter().any(|n| n["code"] == "update_widened"), + "{out}" + ); + assert_eq!(events.last().expect("finished")["exit_code"], 0); + } +} + +#[test] +fn consumer_cargo_set_refresh_failure_keeps_the_widen_and_check_reports_drift() { + let mut harness = Harness::new("update-consumer-cargo-fail"); + harness.bazel_code = 3; + seed_cargo_set(&harness); + let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update drift"), "{err}"); + assert_unchanged(&harness, &[("rs/a/Cargo.toml", CARGO_A)]); + assert!(harness.build_argv.borrow().is_empty()); + let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(read(&harness, "rs/a/Cargo.toml").contains("2.0.0")); + assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); +} + +#[test] +fn consumer_cargo_set_launch_failure_keeps_the_widen() { + let mut harness = Harness::new("update-consumer-cargo-launch-fail"); + harness.io_error = true; + seed_cargo_set(&harness); + let (code, _, err) = harness.run(&["update", "crates:serde", "2.0.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_failed"), "{err}"); + assert!(err.contains("widen kept"), "{err}"); + assert!(read(&harness, "rs/a/Cargo.toml").contains("2.0.0")); + assert_eq!(read(&harness, "rs/b/Cargo.toml"), CARGO_B); +} + +#[test] +fn consumer_cargo_set_signalled_refresh_keeps_the_widen() { + let mut harness = Harness::new("update-consumer-cargo-signalled"); + harness.signalled = true; + seed_cargo_set(&harness); + let (code, out, err) = harness.run(&[ + "update", + "crates:serde", + "2.0.0", + "--output=json", + "--apply", + ]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("signal"), "{err}"); + assert!(out.contains("update_widened"), "{out}"); + assert!(read(&harness, "rs/a/Cargo.toml").contains("2.0.0")); +} + +#[test] +fn consumer_cargo_set_json_reports_widen_then_refresh() { + let harness = Harness::new("update-consumer-cargo-json"); + seed_cargo_set(&harness); + let (code, out, err) = harness.run(&[ + "update", + "crates:serde", + "2.0.0", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let kinds = event_kinds(&events); + assert_eq!(kinds[0], "command_started"); + assert_eq!(kinds[kinds.len() - 1], "command_finished"); + let notices = events_of_kind(&events, "notice"); + assert_eq!(notices.len(), 2, "{out}"); + assert_eq!(notices[0]["code"], serde_json::json!("update_widened")); + assert_eq!(notices[1]["code"], serde_json::json!("update_set_success")); + assert_eq!(err, "", "{err}"); +} + +#[test] +fn consumer_cargo_set_json_refresh_failure_reports_widen_then_error() { + let mut harness = Harness::new("update-consumer-cargo-json-fail"); + harness.bazel_code = 2; + seed_cargo_set(&harness); + let (code, out, err) = harness.run(&[ + "update", + "crates:serde", + "2.0.0", + "--output=json", + "--apply", + ]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("update_failed"), "{err}"); + let kinds_owned = event_kinds(&json_events(&out)).join(","); + assert!(kinds_owned.contains("notice"), "{out}"); + assert!(kinds_owned.contains("error"), "{out}"); +} + +const MAVEN_MODULE: &str = "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(\n name = \"maven\",\n artifacts = [\"junit:junit:4.13.2\"],\n lock_file = \"//third_party:maven_install.json\",\n)\nmaven.install(\n name = \"other\",\n artifacts = [\"junit:junit:4.12\"],\n)\nuse_repo(maven, \"maven\", \"other\")\n"; + +fn seed_maven_set(harness: &Harness) { + harness.write_source("MODULE.bazel", MAVEN_MODULE); + harness.write_source("third_party/maven_install.json", "{}\n"); +} + +#[test] +fn consumer_maven_set_edits_only_its_own_install_and_repins() { + let harness = Harness::new("update-consumer-maven"); + seed_maven_set(&harness); + let (code, out, err) = harness.run(&["update", "maven:junit:junit", "4.13.3", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("refreshed maven via `dx update maven`"), + "{out}" + ); + assert_eq!( + read(&harness, "MODULE.bazel"), + MAVEN_MODULE.replace("junit:junit:4.13.2", "junit:junit:4.13.3") + ); + let runs = harness.build_argv.borrow(); + assert_eq!(runs.len(), 1, "{runs:?}"); + assert!(runs[0].iter().any(|arg| arg == "run"), "{:?}", runs[0]); + assert_eq!(runs[0].last().map(String::as_str), Some("@maven//:pin")); + assert_eq!( + *harness.seen_env.borrow(), + vec![dx_update::backend::maven_env(true)] + ); +} + +#[test] +fn consumer_maven_set_refusals_write_nothing_and_launch_nothing() { + let harness = Harness::new("update-consumer-maven-refuse"); + seed_maven_set(&harness); + let files = [ + ("MODULE.bazel", MAVEN_MODULE), + ("third_party/maven_install.json", "{}\n"), + ]; + for (flag, want) in [ + ("--offline", "offline_required"), + ("--frozen", "frozen_locked"), + ] { + let (code, _, err) = + harness.run(&["update", "maven:junit:junit", "4.13.3", flag, "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains(want), "{err}"); + } + assert_unchanged(&harness, &files); + let module = MAVEN_MODULE.replace("//third_party:maven_install.json", "//:other.json"); + harness.write_source("MODULE.bazel", &module); + let (code, _, err) = harness.run(&["update", "maven:junit:junit", "4.13.3", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("no widen performed"), "{err}"); + assert_unchanged(&harness, &[("MODULE.bazel", &module), files[1]]); + assert!(harness.build_argv.borrow().is_empty()); + assert!(harness.seen_env.borrow().is_empty()); +} + +#[test] +fn consumer_maven_set_refresh_failure_keeps_the_widen() { + let mut harness = Harness::new("update-consumer-maven-fail"); + harness.bazel_code = 3; + seed_maven_set(&harness); + let (code, _, err) = harness.run(&["update", "maven:junit:junit", "4.13.3", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_failed"), "{err}"); + assert!(read(&harness, "MODULE.bazel").contains("junit:junit:4.13.3")); + assert_eq!(read(&harness, "third_party/maven_install.json"), "{}\n"); +} + +#[test] +fn consumer_maven_set_json_reports_widen_then_refresh() { + let harness = Harness::new("update-consumer-maven-json"); + seed_maven_set(&harness); + let (code, out, err) = harness.run(&[ + "update", + "maven:junit:junit", + "4.13.3", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let notices = events_of_kind(&events, "notice"); + assert_eq!(notices.len(), 2, "{out}"); + assert_eq!(notices[0]["code"], serde_json::json!("update_widened")); + assert_eq!(notices[1]["code"], serde_json::json!("update_set_success")); +} + +const NPM_MODULE: &str = "npm = use_extension(\"@aspect_rules_js//npm:extensions.bzl\", \"npm\")\nnpm.npm_translate_lock(name = \"web\", pnpm_lock = \"//web:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"jobs\", pnpm_lock = \"//jobs:pnpm-lock.yaml\")\n"; +const NPM_PACKAGE: &str = + "{\n \"name\": \"app\",\n \"dependencies\": {\n \"left-pad\": \"1.0.0\"\n }\n}\n"; +const NPM_LOCK: &str = "lockfileVersion: '9.0'\n"; + +fn seed_npm_set(harness: &Harness, web_package: &str) { + harness.write_source("MODULE.bazel", NPM_MODULE); + harness.write_source("web/package.json", web_package); + harness.write_source("jobs/package.json", NPM_PACKAGE); + for dir in ["web", "jobs"] { + harness.write_source(&format!("{dir}/pnpm-lock.yaml"), NPM_LOCK); + } +} + +#[test] +fn consumer_npm_set_widens_one_package_and_chains_a_selective_refresh() { + let harness = Harness::new("update-consumer-npm"); + seed_npm_set(&harness, NPM_PACKAGE); + let (code, out, err) = harness.run(&["update", "web:left-pad", "1.3.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("refreshed web:left-pad via `dx update web:left-pad`"), + "{out}" + ); + assert_eq!( + read(&harness, "web/package.json"), + NPM_PACKAGE.replace("1.0.0", "1.3.0") + ); + assert_unchanged( + &harness, + &[ + ("jobs/package.json", NPM_PACKAGE), + ("jobs/pnpm-lock.yaml", NPM_LOCK), + ], + ); + let runs = harness.build_argv.borrow(); + assert_eq!(runs.len(), 1, "{runs:?}"); + assert!(runs[0].iter().any(|arg| arg == "left-pad"), "{:?}", runs[0]); +} + +#[test] +fn consumer_npm_set_minified_manifest_changes_only_the_dependency() { + let harness = Harness::new("update-consumer-npm-minified"); + let minified = "{\"name\":\"app\",\"dependencies\":{\"left-pad\":\"1.0.0\",\"zod\":\"3.0.0\"}}"; + seed_npm_set(&harness, minified); + let (code, out, err) = harness.run(&["update", "web:left-pad", "1.3.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read(&harness, "web/package.json"), + minified.replace("\"left-pad\":\"1.0.0\"", "\"left-pad\":\"1.3.0\"") + ); +} + +#[test] +fn consumer_npm_set_refresh_failure_keeps_the_widen() { + let mut harness = Harness::new("update-consumer-npm-fail"); + harness.bazel_code = 3; + seed_npm_set(&harness, NPM_PACKAGE); + let (code, _, err) = harness.run(&["update", "web:left-pad", "1.3.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update_failed"), "{err}"); + assert!(read(&harness, "web/package.json").contains("1.3.0")); + assert_eq!(read(&harness, "web/pnpm-lock.yaml"), NPM_LOCK); +} + +const PAKET_DEPS: &str = + "source https://api.nuget.org/v3/index.json\nnuget Newtonsoft.Json 12.0.1\n"; + +#[test] +fn consumer_nuget_set_edits_paket_dependencies_only_and_names_the_follow_up() { + let harness = Harness::new("update-consumer-nuget"); + harness.write_source( + "MODULE.bazel", + "paket = use_extension(\"//dotnet/deps:paket.main_extension.bzl\", \"main_extension\")\n", + ); + let files = [ + ("dotnet/paket.lock", "NUGET\n"), + ("dotnet/deps/paket.main.bzl", "main\n"), + ]; + harness.write_source("dotnet/paket.dependencies", PAKET_DEPS); + for (rel, text) in files { + harness.write_source(rel, text); + } + let (code, _, err) = harness.run(&[ + "update", + "nuget:Newtonsoft.Json", + "13.0.3", + "--frozen", + "--apply", + ]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("frozen_locked"), "{err}"); + assert_unchanged(&harness, &[("dotnet/paket.dependencies", PAKET_DEPS)]); + let (code, out, err) = harness.run(&[ + "update", + "nuget:Newtonsoft.Json", + "13.0.3", + "--offline", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("run `paket update` in dotnet, then `dx update nuget`"), + "{out}" + ); + assert!(!out.contains("refreshed"), "{out}"); + assert_eq!( + read(&harness, "dotnet/paket.dependencies"), + PAKET_DEPS.replace("12.0.1", "13.0.3") + ); + assert_unchanged(&harness, &files); + assert!(harness.build_argv.borrow().is_empty()); + assert!(harness.seen_env.borrow().is_empty()); +} + +const GO_MOD: &str = "module example.com/m\n\ngo 1.22\n\nrequire github.com/google/go-cmp v0.6.0\n"; +const GO_SUM: &str = "github.com/google/go-cmp v0.6.0 h1:abc=\n"; +const GO_FILES: [(&str, &str); 4] = [ + ( + "MODULE.bazel", + "bazel_dep(name = \"rules_go\", version = \"0.50.1\")\ngo_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//third_party/go:go.mod\")\n", + ), + ("third_party/go/go.mod", GO_MOD), + ("third_party/go/go.sum", GO_SUM), + ("tools/go.mod", GO_MOD), +]; + +/// A runner that stands in for `go mod download all`: it records each launch and writes +/// the given files into the `-C` scratch directory, as go would. +struct GoRunner { + code: Option, + writes: Vec<(&'static str, String)>, + #[allow(clippy::type_complexity)] + launches: std::cell::RefCell, std::path::PathBuf, Vec<(String, String)>)>>, + scratch: std::cell::RefCell>, +} + +impl GoRunner { + fn new(code: Option, writes: &[(&'static str, &str)]) -> Self { + GoRunner { + code, + writes: writes + .iter() + .map(|(name, text)| (*name, (*text).to_owned())) + .collect(), + launches: Default::default(), + scratch: Default::default(), + } + } +} + +impl dx_process::Runner for GoRunner { + fn run( + &self, + argv: &[String], + cwd: &std::path::Path, + env: &[(&str, &str)], + ) -> std::io::Result { + self.launches.borrow_mut().push(( + argv.to_vec(), + cwd.to_path_buf(), + env.iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + )); + let at = argv.iter().position(|arg| arg == "-C").expect("-C"); + let out = std::path::PathBuf::from(&argv[at + 1]); + for (name, text) in &self.writes { + std::fs::write(out.join(name), text)?; + } + *self.scratch.borrow_mut() = out.parent().map(std::path::Path::to_path_buf); + Ok(dx_process::ChildStatus { + code: self.code, + signal: None, + }) + } +} + +fn go_harness(name: &str) -> Harness { + let harness = Harness::new(name); + for (rel, text) in GO_FILES { + harness.write_source(rel, text); + } + harness +} + +fn run_go(harness: &Harness, runner: &GoRunner, words: &[&str]) -> (i32, String, String) { + let words: Vec = words.iter().map(|word| (*word).to_owned()).collect(); + let invocation = dx_plan::args::parse(&words).expect("parse"); + harness.execute_with(&invocation, runner) +} + +const GO_UPDATE: [&str; 5] = [ + "update", + "go:github.com/google/go-cmp", + "v0.7.0", + "--apply", + "--verbose", +]; + +#[test] +fn consumer_go_set_widens_require_and_refreshes_go_sum_in_a_scratch_dir() { + let harness = go_harness("update-consumer-go"); + let runner = GoRunner::new( + Some(0), + &[ + ("go.sum", "new sums\n"), + ("go.mod", &GO_MOD.replace("v0.6.0", "v0.7.0")), + ], + ); + let (code, out, err) = run_go(&harness, &runner, &GO_UPDATE); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("refreshed go via `dx update go`"), "{out}"); + assert_eq!( + read(&harness, "third_party/go/go.mod"), + GO_MOD.replace("v0.6.0", "v0.7.0") + ); + assert_eq!(read(&harness, "third_party/go/go.sum"), "new sums\n"); + assert_unchanged(&harness, &[("tools/go.mod", GO_MOD), GO_FILES[0]]); + let launches = runner.launches.borrow(); + assert_eq!(launches.len(), 1, "{launches:?}"); + let (argv, cwd, env) = &launches[0]; + let at = argv.iter().position(|arg| arg == "-C").expect("-C"); + #[allow(clippy::disallowed_methods)] + let mut head = dx_process::startup_argv(&[]); + head.extend( + ["run", "--config=dx_build_dev", "@rules_go//go", "--", "-C"] + .iter() + .map(|word| (*word).to_owned()), + ); + assert_eq!(argv[..=at], head[..], "{argv:?}"); + assert_eq!(argv[at + 2..], ["mod", "download", "all"], "{argv:?}"); + let out_dir = std::path::PathBuf::from(&argv[at + 1]); + assert!(out_dir.ends_with("out")); + assert!(!out_dir.starts_with(&harness.workspace)); + assert_eq!(cwd, &harness.workspace); + let env: std::collections::BTreeMap<&str, &str> = env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + assert_eq!(env["GOENV"], "off"); + assert_eq!(env["GOWORK"], "off"); + assert_eq!(env["GOTOOLCHAIN"], "local"); + let scratch = out_dir.parent().expect("scratch"); + for key in ["GOMODCACHE", "GOPATH", "GOCACHE"] { + assert!(std::path::Path::new(env[key]).starts_with(scratch), "{key}"); + } + assert!(!scratch.exists(), "scratch must be removed"); +} + +#[test] +fn consumer_go_set_json_reports_the_refresh() { + let harness = go_harness("update-consumer-go-json"); + let runner = GoRunner::new( + Some(0), + &[ + ("go.sum", "new sums\n"), + ("go.mod", &GO_MOD.replace("v0.6.0", "v0.7.0")), + ], + ); + let (code, out, err) = run_go( + &harness, + &runner, + &[ + "update", + "go:github.com/google/go-cmp", + "v0.7.0", + "--apply", + "--output=json", + ], + ); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let kinds = event_kinds(&events); + assert_eq!( + kinds, + ["command_started", "notice", "notice", "command_finished"], + "{out}" + ); + assert_eq!(read(&harness, "third_party/go/go.sum"), "new sums\n"); +} + +#[test] +fn consumer_go_set_refusals_write_nothing_and_launch_nothing() { + for (name, flag, want) in [ + ("frozen", "--frozen", "frozen_locked"), + ("offline", "--offline", "offline_required"), + ] { + let harness = go_harness(&format!("update-consumer-go-refuse-{name}")); + let runner = GoRunner::new(Some(0), &[("go.sum", "new sums\n")]); + let words = [ + "update", + "go:github.com/google/go-cmp", + "v0.7.0", + "--apply", + flag, + ]; + let (code, out, err) = run_go(&harness, &runner, &words); + assert_eq!(code, 1, "{name}: {out}{err}"); + assert!(format!("{out}{err}").contains(want), "{name}: {out}{err}"); + assert_unchanged(&harness, &GO_FILES); + assert!(runner.launches.borrow().is_empty(), "{name}"); + } +} + +#[test] +fn consumer_go_set_failed_refresh_keeps_the_widen_and_go_sum() { + for (name, code, writes, want) in [ + ( + "exit", + Some(1), + vec![("go.sum", "new sums\n")], + "updater exited 1", + ), + ( + "mod", + Some(0), + vec![("go.sum", "new sums\n"), ("go.mod", "module changed\n")], + "go changed go.mod", + ), + ] { + let harness = go_harness(&format!("update-consumer-go-fail-{name}")); + let runner = GoRunner::new(code, &writes); + let (status, out, err) = run_go(&harness, &runner, &GO_UPDATE); + assert_eq!(status, 1, "{name}: {out}{err}"); + assert!(err.contains(want), "{name}: {err}"); + assert!( + err.contains("widen kept in third_party/go/go.mod") + && err.contains("`go mod tidy` in third_party/go, then `dx update go`"), + "{name}: {err}" + ); + assert!(!format!("{out}{err}").contains("and refreshed"), "{name}"); + assert_eq!( + read(&harness, "third_party/go/go.mod"), + GO_MOD.replace("v0.6.0", "v0.7.0") + ); + assert_unchanged( + &harness, + &[("third_party/go/go.sum", GO_SUM), ("tools/go.mod", GO_MOD)], + ); + let scratch = runner.scratch.borrow().clone().expect("launched"); + assert!(!scratch.exists(), "{name}: scratch must be removed"); + } +} + +#[test] +fn consumer_go_set_json_reports_a_failed_refresh() { + let harness = go_harness("update-consumer-go-json-fail"); + let runner = GoRunner::new(Some(1), &[]); + let (code, out, _) = run_go( + &harness, + &runner, + &[ + "update", + "go:github.com/google/go-cmp", + "v0.7.0", + "--apply", + "--output=json", + ], + ); + assert_eq!(code, 1, "{out}"); + assert_eq!( + event_kinds(&json_events(&out)), + ["command_started", "notice", "error", "command_finished"], + "{out}" + ); + assert_unchanged(&harness, &[("third_party/go/go.sum", GO_SUM)]); +} + +#[test] +fn undeclared_set_selectors_exit_two_and_list_valid_names() { + let harness = Harness::new("update-consumer-builtin"); + harness.write_source("MODULE.bazel", CONSUMER_MODULE); + let (code, _, err) = harness.run(&["update", "cargo:anyhow", "1.2.3", "--apply"]); + assert_eq!(code, 2, "{err}"); + assert!( + err.contains("expected pypi-frontend, bazel, containers, github-actions"), + "{err}" + ); +} + +#[test] +fn unknown_set_without_module_bazel_writes_nothing() { + let harness = Harness::new("update-unknown-no-module"); + harness.write_source("package.json", "{}\n"); + let (code, _, err) = harness.run(&["update", "npm:react", "1.0.0", "--apply"]); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("unknown dependency set \"npm\""), "{err}"); + assert_eq!(read(&harness, "package.json"), "{}\n"); +} + +#[test] +fn unreadable_and_non_utf8_manifests_fail_without_refresh() { + for json in [false, true] { + for invalid_utf8 in [false, true] { + let harness = Harness::new("update-unreadable"); + if invalid_utf8 { + std::fs::write(harness.workspace.join(".bazelversion"), [0xff]) + .expect("invalid bytes"); + } + let output = if json { + "--output=json" + } else { + "--output=text" + }; + let (code, out, err) = + harness.run(&["update", "bazel:.bazelversion", "9.3.0", output, "--apply"]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains(if invalid_utf8 { + "not valid UTF-8" + } else { + "cannot read" + })); + assert!(harness.seen_env.borrow().is_empty()); + if json { + let events: Vec = out + .lines() + .map(|line| serde_json::from_str(line).expect("event")) + .collect(); + assert_eq!(events[1]["code"], super::CODE_UPDATE_WIDEN_FAILED); + assert_eq!(events.last().expect("finished")["exit_code"], 1); + } + } + } +} + +#[test] +fn frozen_widen_needed_fails_before_any_manifest_write() { + { + let (selector, version, path, before) = ( + "dx_crates:demo", + "1.2.3", + HELLO_CARGO, + "[dependencies]\ndemo = \"1\"\n", + ); + let harness = Harness::new("update-frozen-widen"); + seed(&harness, path, before); + let (code, out, err) = harness.run(&[ + "update", + selector, + version, + "--frozen", + "--output=json", + "--apply", + ]); + assert_eq!(code, 1, "{selector}: {out}{err}"); + assert!( + err.contains(&format!("dx: {}: ", super::CODE_FROZEN_LOCKED)), + "{selector}: {err}" + ); + assert!( + err.contains("no manifest changes performed"), + "{selector}: {err}" + ); + assert!( + !err.contains("offline_required"), + "{selector}: frozen stays independent: {err}" + ); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(path)).expect("manifest"), + before, + "{selector}: frozen must not widen" + ); + assert!( + harness.seen_env.borrow().is_empty(), + "{selector}: frozen launches nothing" + ); + let events = json_events(&out); + assert_eq!(event(&events, "error")["code"], super::CODE_FROZEN_LOCKED); + let finished = events.last().expect("finished"); + assert_eq!(finished["event"], "command_finished"); + assert_eq!(finished["exit_code"], 1); + assert_eq!(finished["results_complete"], false); + } +} + +#[test] +fn dry_run_rejects_bad_selector_and_version() { + let harness = Harness::new("update-dryrun-bad-selector"); + let (code, _, err) = harness.run(&["update", "crates", "1.2.3"]); + assert_eq!(code, 2, "{err}"); + let harness = Harness::new("update-dryrun-bad-version"); + let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "nope!!!"]); + assert_eq!(code, 2, "{err}"); +} + +#[test] +fn go_update_leaves_a_malformed_go_mod_unchanged() { + let harness = Harness::new("update-go-malformed"); + let before = "module example.com/root\nrequire (\n\texample.com/demo v1.2.0 // v1.0.0\n"; + harness.write_source("third_party/go/go.mod", before); + let (code, out, err) = harness.run(&[ + "update", + "go:example.com/demo", + "1.3.0", + "--offline", + "--apply", + ]); + assert_ne!(code, 0, "{out}{err}"); + assert_eq!( + std::fs::read_to_string(harness.workspace.join("third_party/go/go.mod")).expect("read"), + before + ); +} + +#[test] +fn live_bazel_file_only_has_no_refresh_launch() { + let harness = Harness::new("update-live-bazel-fileonly"); + harness.write_source(".bazelversion", "9.2.0\n"); + let (code, out, err) = harness.run(&["update", "bazel:.bazelversion", "9.3.0", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains("widened bazel:.bazelversion to 9.3.0"), + "{out}" + ); + assert!(out.contains("flag-diff"), "{out}"); + assert!(!out.contains("automatically"), "{out}"); + assert!(err.is_empty(), "{err}"); + assert!( + harness.seen_env.borrow().is_empty(), + "file-only chains nothing" + ); +} + +#[test] +fn live_bazel_module_edits_one_literal_version_atomically() { + let harness = Harness::new("update-live-bazel-module"); + let before = "module(name = 'demo')\n\n# bazel_dep(name = \"rules_cc\", version = \"0.0.1\")\nNOTE = \"bazel_dep(name = 'rules_cc', version = '0.0.2')\"\nbazel_dep(\n version = '0.2.21', # ) \" (\n name = 'rules_cc',\n repo_name = \"cc\",\n)\nbazel_dep(name = \"rules_rust\", version = V)\n"; + let after = before.replace("'0.2.21'", "'0.2.22'"); + harness.write_source("MODULE.bazel", before); + let read = || std::fs::read_to_string(harness.workspace.join("MODULE.bazel")).expect("read"); + let (code, _, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("update drift"), "{err}"); + assert_eq!(read(), before, "check must not write"); + let (code, out, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("widened bazel:rules_cc to 0.2.22"), "{out}"); + assert_eq!(read(), after); + let (code, out, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(read(), after, "second run is a no-op"); + { + let (selector, reason) = ( + "bazel:rules_rust", + "bazel_dep \"rules_rust\" at line 10 has a computed version", + ); + let (code, _, err) = harness.run(&["update", selector, "1.0.0", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains(reason), "{err}"); + assert_eq!(read(), after, "{selector} must not write"); + } + harness.write_source( + "MODULE.bazel", + "bazel_dep(name = 'rules_cc', version = '0.2.21'\n", + ); + let (code, _, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("MODULE.bazel does not parse at line"), "{err}"); + assert_eq!(read(), "bazel_dep(name = 'rules_cc', version = '0.2.21'\n"); +} + +const SPLIT_MODULE: &str = "# floor: equals tested\nbazel_dep(name = \"rules_cc\", version = \"0.2.21\")\nsingle_version_override(\n module_name = \"rules_cc\",\n version = \"0.2.21\",\n)\n"; + +#[test] +fn live_bazel_update_targets_the_tested_override_or_the_floor() { + let harness = Harness::new("update-live-bazel-floor"); + harness.write_source("MODULE.bazel", SPLIT_MODULE); + let read = || std::fs::read_to_string(harness.workspace.join("MODULE.bazel")).expect("read"); + let (code, out, err) = harness.run(&["update", "bazel:rules_cc", "0.2.22", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + let tested = SPLIT_MODULE.replace(" version = \"0.2.21\",", " version = \"0.2.22\","); + assert_eq!(read(), tested); + let (code, out, err) = harness.run(&[ + "update", + "bazel:rules_cc", + "0.2.22", + "--floor", + "--reason", + "oldest passing the floor job", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!( + read(), + tested + .replace( + "# floor: equals tested", + "# floor: oldest passing the floor job" + ) + .replace("version = \"0.2.21\")", "version = \"0.2.22\")") + ); +} + +#[test] +fn floor_flags_and_order_refusals_are_usage_errors_that_write_nothing() { + let harness = Harness::new("update-floor-refusals"); + harness.write_source("MODULE.bazel", SPLIT_MODULE); + let read = || std::fs::read_to_string(harness.workspace.join("MODULE.bazel")).expect("read"); + for (args, needle) in [ + ( + vec!["update", "bazel:rules_cc", "0.2.20", "--floor", "--apply"], + "--floor needs --reason", + ), + ( + vec![ + "update", + "bazel:rules_cc", + "0.2.20", + "--reason", + "x", + "--apply", + ], + "--reason only applies with --floor", + ), + ( + vec![ + "update", + "github-actions:actions/checkout", + GHA_SHA, + "--floor", + "--reason", + "x", + ], + "--floor applies only to a MODULE.bazel bazel_dep", + ), + ( + vec![ + "update", + "bazel:rules_cc", + "0.3.0", + "--floor", + "--reason", + "x", + "--apply", + ], + "above its tested version", + ), + ( + vec!["update", "bazel:rules_cc", "0.2.0", "--apply"], + "below its floor", + ), + ] { + let (code, _, err) = harness.run(&args); + assert_eq!(code, 2, "{args:?}: {err}"); + assert!(err.contains(needle), "{args:?}: {err}"); + assert_eq!(read(), SPLIT_MODULE, "{args:?} must not write"); + } +} + +fn tag_request() -> dx_update::edit::UpdateRequest { + dx_update::edit::UpdateRequest::parse("github-actions:actions/checkout", "v5").expect("request") +} + +#[test] +fn gha_tag_is_resolved_through_the_fetch() { + let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let seen = std::cell::RefCell::new(Vec::new()); + let fetch = |url: &str| { + seen.borrow_mut().push(url.to_owned()); + Ok(format!("{{\"sha\": \"{sha}\"}}")) + }; + let resolved = resolve_gha_tag_request(&tag_request(), None, false, &fetch).expect("resolved"); + assert_eq!( + *seen.borrow(), + ["https://api.github.com/repos/actions/checkout/commits/v5"] + ); + let text = "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4\n"; + let widened = resolved.plan_edit(text).expect("edit"); + assert!(widened.ends_with(&format!("@{sha} # v5\n")), "{widened}"); +} + +#[test] +fn gha_tag_fetch_error_and_offline_resolve_nothing() { + let failing = |_: &str| Err("HTTP 404".to_owned()); + let (code, message) = + resolve_gha_tag_request(&tag_request(), None, false, &failing).expect_err("error"); + assert_eq!(code, CODE_UPDATE_WIDEN_FAILED); + assert!(message.contains("HTTP 404"), "{message}"); + let calls = std::cell::Cell::new(0); + let counting = |_: &str| { + calls.set(calls.get() + 1); + Err("unused".to_owned()) + }; + let (code, _) = + resolve_gha_tag_request(&tag_request(), None, true, &counting).expect_err("error"); + assert_eq!(code, CODE_OFFLINE_REQUIRED); + assert_eq!(calls.get(), 0); +} + +#[test] +fn live_github_tag_resolution_matrix() { + let harness = Harness::new("update-live-gha-tag"); + harness.write_source( + ".github/workflows/ci.yml", + "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n", + ); + let (code, _, err) = harness.run(&[ + "update", + "github-actions:actions/checkout", + "v5", + "--offline", + "--apply", + ]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("offline_required"), "{err}"); + + let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let commit_json = format!("{{\"sha\": \"{sha}\", \"commit\": {{\"message\": \"v5\"}}}}"); + let mut harness = Harness::new("update-live-gha-resolved"); + harness.write_source( + ".github/workflows/ci.yml", + "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n - uses: actions/cache@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v6\n", + ); + harness.write_source( + ".github/workflows/reusable.yaml", + "jobs:\n b:\n steps:\n - uses: actions/checkout@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v6\n", + ); + harness.write_source("commit.json", &commit_json); + harness.edit_commit_json = Some(harness.workspace.join("commit.json").display().to_string()); + let (code, out, err) = harness.run(&[ + "update", + "github-actions:actions/checkout", + "v5", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(err, "", "{err}"); + let widened = std::fs::read_to_string(harness.workspace.join(".github/workflows/ci.yml")) + .expect("workflow"); + assert!( + widened.contains(&format!("actions/checkout@{sha}")), + "{widened}" + ); + assert!(widened.contains("actions/cache@bbbb"), "{widened}"); + assert!( + widened.contains(&format!("actions/checkout@{sha} # v5")), + "{widened}" + ); + let widened_yaml = + std::fs::read_to_string(harness.workspace.join(".github/workflows/reusable.yaml")) + .expect("workflow"); + assert!( + widened_yaml.contains(&format!("actions/checkout@{sha}")), + "{widened_yaml}" + ); + let events = json_events(&out); + assert!( + events.iter().any(|event| event["code"] == "update_widened"), + "{out}" + ); + assert_eq!(events.last().expect("finished")["exit_code"], 0); + + let mut harness = Harness::new("update-live-gha-nochange"); + harness.write_source( + ".github/workflows/ci.yml", + &format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{sha} # v5\n"), + ); + harness.write_source("commit.json", &commit_json); + harness.edit_commit_json = Some(harness.workspace.join("commit.json").display().to_string()); + let (code, out, err) = harness.run(&[ + "update", + "github-actions:actions/checkout", + "v5", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + assert_eq!(err, "", "{err}"); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(".github/workflows/ci.yml")) + .expect("workflow"), + format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{sha} # v5\n") + ); + let events = json_events(&out); + assert!( + events + .iter() + .any(|event| event["code"] == "update_no_change"), + "{out}" + ); + assert_eq!(events.last().expect("finished")["exit_code"], 0); + + let (code, out, err) = + harness.run(&["update", "github-actions:actions/checkout", "v5", "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!(out.contains("no-change"), "{out}"); + assert!(err.is_empty(), "{err}"); + + for (name, body) in [ + ("missing-file", None), + ("invalid-json", Some("not json")), + ("upstream-error", Some("{\"message\": \"Not Found\"}")), + ("missing-sha", Some("{\"commit\": {}}")), + ("invalid-sha", Some("{\"sha\": \"v5\"}")), + ] { + let mut harness = Harness::new(&format!("update-live-gha-fail-{name}")); + harness.write_source( + ".github/workflows/ci.yml", + "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n", + ); + match body { + Some(body) => { + harness.write_source("commit.json", body); + harness.edit_commit_json = + Some(harness.workspace.join("commit.json").display().to_string()); + } + None => { + harness.edit_commit_json = + Some(harness.workspace.join("absent.json").display().to_string()); + } + } + let (code, out, err) = + harness.run(&["update", "github-actions:actions/checkout", "v5", "--apply"]); + assert_eq!(code, 1, "{name}: {out}{err}"); + assert!(err.contains("update_widen_failed"), "{name}: {err}"); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(".github/workflows/ci.yml")) + .expect("workflow"), + "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n", + "{name}: failed lookup must not widen" + ); + } +} + +#[test] +fn live_github_actions_widens_every_workflow_that_pins_the_action() { + let harness = Harness::new("update-live-gha-every"); + let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + for name in ["ci.yml", "reusable.yaml"] { + harness.write_source( + &format!(".github/workflows/{name}"), + "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n - uses: actions/cache@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v6\n", + ); + } + harness.write_source( + ".github/workflows/update.yml", + "jobs:\n b:\n steps:\n - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # v0.19.0\n", + ); + harness.write_source(".github/workflows/README.md", "not a workflow\n"); + let (code, out, err) = + harness.run(&["update", "github-actions:actions/checkout", sha, "--apply"]); + assert_eq!(code, 0, "{out}{err}"); + assert!(err.is_empty(), "{err}"); + assert!( + out.contains( + "widened github-actions:actions/checkout to aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa in \ + .github/workflows/ci.yml, .github/workflows/reusable.yaml" + ), + "{out}" + ); + for name in ["ci.yml", "reusable.yaml"] { + let widened = + std::fs::read_to_string(harness.workspace.join(format!(".github/workflows/{name}"))) + .expect("workflow"); + assert!( + widened.contains(&format!("actions/checkout@{sha} # v7")), + "{widened}" + ); + assert!(widened.contains("actions/cache@bbbb"), "{widened}"); + } + let untouched = std::fs::read_to_string(harness.workspace.join(".github/workflows/update.yml")) + .expect("workflow"); + assert!(untouched.contains("setup-bazel"), "{untouched}"); +} + +#[test] +fn live_github_actions_fails_closed_and_writes_no_workflow() { + let sha = "cccccccccccccccccccccccccccccccccccccccc"; + let cases = [ + ( + "ambiguous", + vec![( + "ci.yml", + vec![ + "jobs:\n b:\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n", + " - uses: actions/checkout@2222222222222222222222222222222222222222\n", + ], + )], + "ambiguous requirement", + ), + ( + "unpinned", + vec![( + "ci.yml", + vec![ + "jobs:\n b:\n steps:\n - uses: bazel-contrib/setup-bazel@cccccccccccccccccccccccccccccccccccccccc # v0.19.0\n", + ], + )], + "no declared requirement", + ), + ( + "malformed", + vec![ + ( + "a.yml", + vec![ + "jobs:\n b:\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n", + ], + ), + ("b.yml", vec!["jobs: [\n"]), + ], + "not valid YAML", + ), + ( + "aliased", + vec![ + ( + "a.yml", + vec![ + "jobs:\n b:\n steps:\n - uses: actions/checkout@1111111111111111111111111111111111111111\n", + ], + ), + ( + "b.yml", + vec!["x: &s\n - uses: actions/checkout@v1\njobs:\n b:\n steps: *s\n"], + ), + ], + "aliases or merge keys", + ), + ( + "no-workflows", + vec![("README.md", vec!["not a workflow\n"])], + "no declared requirement", + ), + ]; + for (name, workflows, expected) in cases { + let harness = Harness::new(&format!("update-live-gha-{name}")); + for (file, lines) in &workflows { + harness.write_source(&format!(".github/workflows/{file}"), &lines.concat()); + } + let (code, _, err) = + harness.run(&["update", "github-actions:actions/checkout", sha, "--apply"]); + assert_eq!(code, 1, "{name}: {err}"); + assert!(err.contains("update_widen_failed"), "{name}: {err}"); + assert!(err.contains(expected), "{name}: {err}"); + assert!(err.contains(".github/workflows"), "{name}: {err}"); + for (file, lines) in &workflows { + let text = std::fs::read_to_string( + harness.workspace.join(format!(".github/workflows/{file}")), + ) + .expect("workflow"); + assert_eq!(&text, &lines.concat(), "{name} must widen nothing"); + } + } + let missing = Harness::new("update-live-gha-missing-dir"); + missing.write_source(".bazelversion", "9.2.0\n"); + let (code, _, err) = + missing.run(&["update", "github-actions:actions/checkout", sha, "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!(err.contains("cannot list .github/workflows"), "{err}"); +} + +#[test] +fn live_json_emits_planned_or_widened_and_finished() { + let harness = Harness::new("update-live-json"); + harness.write_source(".bazelversion", "9.2.0\n"); + let (code, out, err) = harness.run(&[ + "update", + "bazel:.bazelversion", + "9.3.0", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + let events = json_events(&out); + let kinds = event_kinds(&events); + assert_eq!(kinds[0], "command_started"); + assert_eq!(kinds[kinds.len() - 1], "command_finished"); + assert!(kinds.contains(&"notice")); + assert_eq!( + events.last().expect("finished")["exit_code"], + serde_json::json!(0) + ); + assert_eq!(err, "", "{err}"); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(".bazelversion")).expect("read"), + "9.3.0\n" + ); +} + +const GHA_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +/// One already-current declaration per set: selector, version, manifest, text. +fn current_pins() -> Vec<(&'static str, &'static str, &'static str, String)> { + vec![ + ( + "bazel:rules_cc", + "0.2.22", + "MODULE.bazel", + "bazel_dep(name = \"rules_cc\", version = \"0.2.22\")\n".to_owned(), + ), + ( + "dx_crates:anyhow", + "1.2.3", + HELLO_CARGO, + "[dependencies]\nanyhow = \"1.2.3\"\n".to_owned(), + ), + ( + "github-actions:actions/checkout", + GHA_SHA, + ".github/workflows/ci.yml", + format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{GHA_SHA} # v5\n"), + ), + ] +} + +#[test] +fn unchanged_version_reports_no_change_for_every_set() { + for (selector, version, path, text) in current_pins() { + let harness = Harness::new("update-no-change-every-set"); + seed(&harness, path, &text); + let (code, out, err) = harness.run(&["update", selector, version, "--apply"]); + assert_eq!(code, 0, "{selector}: {out}{err}"); + assert!(out.contains("no-change"), "{selector}: {out}"); + assert!(err.is_empty(), "{selector}: {err}"); + let (code, out, err) = + harness.run(&["update", selector, version, "--output=json", "--apply"]); + assert_eq!(code, 0, "{selector}: {out}{err}"); + let events = json_events(&out); + assert_eq!( + event(&events, "notice")["code"], + "update_no_change", + "{out}" + ); + assert_eq!(events.last().expect("finished")["exit_code"], 0); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(path)).expect("manifest"), + text, + "{selector}: no-change writes nothing" + ); + assert!( + harness.seen_env.borrow().is_empty(), + "{selector}: no-change refreshes nothing" + ); + } +} + +#[test] +fn unchanged_version_check_reports_no_change_like_apply() { + for (selector, version, path, text) in current_pins() { + let harness = Harness::new("update-no-change-check"); + seed(&harness, path, &text); + let (code, out, err) = harness.run(&["update", selector, version]); + assert_eq!(code, 0, "{selector}: {out}{err}"); + assert!(out.contains("no-change"), "{selector}: {out}"); + assert!(err.is_empty(), "{selector}: {err}"); + let (code, out, err) = harness.run(&["update", selector, version, "--output=json"]); + assert_eq!(code, 0, "{selector}: {out}{err}"); + let events = json_events(&out); + assert_eq!( + event(&events, "notice")["code"], + "update_no_change", + "{out}" + ); + assert_eq!(events.last().expect("finished")["exit_code"], 0); + assert!( + harness.seen_env.borrow().is_empty(), + "{selector}: check refreshes nothing" + ); + } +} + +#[test] +fn unchanged_version_under_frozen_and_offline_exits_zero() { + for flag in ["--frozen", "--offline"] { + for (selector, version, path, text) in current_pins() { + let harness = Harness::new("update-no-change-gated"); + seed(&harness, path, &text); + let (code, out, err) = harness.run(&[ + "update", + selector, + version, + flag, + "--output=json", + "--apply", + ]); + assert_eq!(code, 0, "{selector} {flag}: {out}{err}"); + let events = json_events(&out); + assert_eq!( + event(&events, "notice")["code"], + "update_no_change", + "{out}" + ); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(path)).expect("manifest"), + text + ); + assert!(harness.seen_env.borrow().is_empty(), "{selector} {flag}"); + } + } +} + +#[test] +fn github_actions_writes_only_changed_workflows() { + let harness = Harness::new("update-gha-only-changed"); + let pinned = + format!("jobs:\n b:\n steps:\n - uses: actions/checkout@{GHA_SHA} # v5\n"); + harness.write_source( + ".github/workflows/a.yml", + "jobs:\n b:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4\n", + ); + harness.write_source(".github/workflows/b.yml", &pinned); + let (code, out, err) = harness.run(&[ + "update", + "github-actions:actions/checkout", + GHA_SHA, + "--apply", + ]); + assert_eq!(code, 0, "{out}{err}"); + assert!( + out.contains(&format!( + "widened github-actions:actions/checkout to {GHA_SHA} in .github/workflows/a.yml" + )), + "{out}" + ); + assert!(!out.contains("b.yml"), "{out}"); + assert!( + std::fs::read_to_string(harness.workspace.join(".github/workflows/a.yml")) + .expect("workflow") + .contains(GHA_SHA) + ); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(".github/workflows/b.yml")) + .expect("workflow"), + pinned + ); +} + +const CARGO_TREE: [(&str, &str); 3] = [ + ("cli/a/Cargo.toml", "[dependencies]\nanyhow = \"1\"\n"), + ("cli/b/Cargo.toml", "[dependencies]\nserde = \"1\"\n"), + ( + "rust/tests/fixtures/hello/Cargo.toml", + "[dev-dependencies]\nanyhow = { version = \"1.0\" }\n", + ), +]; + +fn read(harness: &Harness, path: &str) -> String { + std::fs::read_to_string(harness.workspace.join(path)).expect("manifest") +} + +#[test] +fn cargo_check_mode_reports_drift_across_manifests() { + let harness = Harness::new("update-cargo-check-manifests"); + seed_cargo(&harness, &CARGO_TREE); + let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "1.2.3"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains( + "needs widening to 1.2.3 in rust/tests/fixtures/hello/Cargo.toml, cli/a/Cargo.toml" + ), + "{err}" + ); + for (path, text) in CARGO_TREE { + assert_eq!(read(&harness, path), text, "check must not write {path}"); + } + assert!(harness.seen_env.borrow().is_empty()); +} + +#[test] +fn cargo_without_a_declared_hub_is_an_unknown_set() { + let harness = Harness::new("update-cargo-no-module"); + harness.write_source(HELLO_CARGO, "[dependencies]\nanyhow = \"1\"\n"); + let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "1.2.3", "--apply"]); + assert_eq!(code, 2, "{err}"); + assert!( + err.contains("unknown dependency set \"dx_crates\""), + "{err}" + ); + assert_eq!( + read(&harness, HELLO_CARGO), + "[dependencies]\nanyhow = \"1\"\n" + ); + assert!(harness.seen_env.borrow().is_empty()); +} + +#[test] +fn cargo_path_dependency_in_one_manifest_fails_closed_writing_nothing() { + let harness = Harness::new("update-cargo-path-dep"); + let tree = [ + CARGO_TREE[0], + ( + "cli/b/Cargo.toml", + "[dependencies]\nanyhow = { path = \"../anyhow\" }\n", + ), + ]; + seed_cargo(&harness, &tree); + let (code, _, err) = harness.run(&["update", "dx_crates:anyhow", "1.2.3", "--apply"]); + assert_eq!(code, 1, "{err}"); + assert!( + err.contains("unsupported manifest shape in cli/b/Cargo.toml"), + "{err}" + ); + for (path, text) in tree { + assert_eq!(read(&harness, path), text, "{path}"); + } + assert!(harness.seen_env.borrow().is_empty()); +} + +#[test] +fn default_check_json_reports_drift_without_writing() { + let harness = Harness::new("update-default-json"); + harness.write_source(".bazelversion", "9.2.0\n"); + let (code, out, err) = + harness.run(&["update", "bazel:.bazelversion", "9.3.0", "--output=json"]); + assert_eq!(code, 1, "{out}{err}"); + assert!(err.contains("update_widen_failed"), "{err}"); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(".bazelversion")).expect("read"), + "9.2.0\n", + "check must not write" + ); + let events = json_events(&out); + assert_eq!( + event(&events, "error")["code"], + super::CODE_UPDATE_WIDEN_FAILED + ); + assert_eq!( + events.last().expect("finished")["exit_code"], + serde_json::json!(1) + ); + let (code, _, _) = harness.run(&[ + "update", + "bazel:.bazelversion", + "9.3.0", + "--output=json", + "--apply", + ]); + assert_eq!(code, 0); + assert_eq!( + std::fs::read_to_string(harness.workspace.join(".bazelversion")).expect("read"), + "9.3.0\n" + ); +} From 0a86766d0540b2f58d0c9104000f7395fed2ad35 Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Mon, 9 Nov 2026 17:11:24 +0100 Subject: [PATCH 3/5] dx: split test_reports.rs into account, collect and report modules (part of #10053) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hwESun1HL2eSVd1d5B --- cli/cli/BUILD.bazel | 6 + cli/cli/src/exec/test_reports.rs | 1405 +--------------------- cli/cli/src/exec/test_reports/account.rs | 625 ++++++++++ cli/cli/src/exec/test_reports/collect.rs | 337 ++++++ cli/cli/src/exec/test_reports/report.rs | 458 +++++++ 5 files changed, 1434 insertions(+), 1397 deletions(-) create mode 100644 cli/cli/src/exec/test_reports/account.rs create mode 100644 cli/cli/src/exec/test_reports/collect.rs create mode 100644 cli/cli/src/exec/test_reports/report.rs diff --git a/cli/cli/BUILD.bazel b/cli/cli/BUILD.bazel index 7348fe90f1..2c73a13d7b 100644 --- a/cli/cli/BUILD.bazel +++ b/cli/cli/BUILD.bazel @@ -82,6 +82,9 @@ rust_library( "src/exec/sync_prune.rs", "src/exec/test_filter.rs", "src/exec/test_reports.rs", + "src/exec/test_reports/account.rs", + "src/exec/test_reports/collect.rs", + "src/exec/test_reports/report.rs", "src/exec/umbrella.rs", "src/exec/umbrella_tests.rs", "src/exec/update.rs", @@ -340,6 +343,9 @@ rust_test( "src/exec/test_case_launcher_tests.rs", "src/exec/test_filter.rs", "src/exec/test_reports.rs", + "src/exec/test_reports/account.rs", + "src/exec/test_reports/collect.rs", + "src/exec/test_reports/report.rs", "src/exec/test_reports_tests.rs", "src/exec/test_support.rs", "src/exec/text_output_tests.rs", diff --git a/cli/cli/src/exec/test_reports.rs b/cli/cli/src/exec/test_reports.rs index ea15809eb2..d44e8edbec 100644 --- a/cli/cli/src/exec/test_reports.rs +++ b/cli/cli/src/exec/test_reports.rs @@ -24,1403 +24,14 @@ use std::collections::BTreeMap; use std::io::{BufReader, Write}; use std::path::Path; -/// Returns one exec path spelled without the build directory, so a report reads the same on every host. -fn stable_exec_path(exec_path: &Path) -> String { - let text = dx_path::posix(exec_path); - if let Some(out) = text.find("/bazel-testlogs/") { - return text[out + "/bazel-testlogs/".len()..].to_owned(); - } - if let Some(out) = text.find("/testlogs/") { - return text[out + "/testlogs/".len()..].to_owned(); - } - if let Some(out) = text.find("/bazel-out/") { - let rest = &text[out + "/bazel-out/".len()..]; - return match rest.find("/bin/") { - Some(bin) => rest[bin + "/bin/".len()..].to_owned(), - None => rest.to_owned(), - }; - } - if let Some(bin) = text.find("/bin/") { - return text[bin + "/bin/".len()..].to_owned(); - } - exec_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or(text) -} - -/// Bazel's exit code when every test built and ran but some failed. -const BAZEL_TESTS_FAILED: i32 = 3; - -/// Whether a test outcome counts as a failed test. -fn is_failed(outcome: &str) -> bool { - matches!(outcome, "failed" | "timeout") -} - -fn unusable_detail(count: usize, first: &str) -> String { - if count > 1 { - format!("{count} missing or invalid results: {first}") - } else { - first.to_owned() - } -} - -/// True for the names Bazel reports a test's coverage result under. -fn is_coverage_output(name: &str) -> bool { - name == "coverage.dat" || name == "test.lcov" -} - -/// True for result files that carry coverage evidence. -fn is_coverage_file(file: &TestResultFile) -> bool { - is_coverage_output(&file.name) -} - -/// The latest attempt Bazel reported for each result's named coverage output. -fn final_coverage_attempts(results: &[TestResultRecord]) -> BTreeMap<(&str, u32, u32, &str), u32> { - let mut finals = BTreeMap::new(); - for result in results { - for file in &result.files { - if !is_coverage_output(&file.name) { - continue; - } - let key = ( - result.label.as_str(), - result.run, - result.shard, - file.name.as_str(), - ); - let seen = finals.entry(key).or_insert(0); - *seen = (*seen).max(result.attempt); - } - } - finals -} - -/// Marks each outcome a later attempt of the same target, configuration, run and shard replaced. -fn mark_superseded_attempts(reported: &mut [TestOutcome]) { - let mut latest: BTreeMap<(String, Option, u32, u32), u32> = BTreeMap::new(); - for outcome in reported.iter() { - if let (Some(run), Some(shard), Some(attempt)) = - (outcome.run, outcome.shard, outcome.attempt) - { - let key = ( - outcome.target.clone(), - outcome.configuration.clone(), - run, - shard, - ); - let seen = latest.entry(key).or_insert(attempt); - *seen = (*seen).max(attempt); - } - } - for outcome in reported.iter_mut() { - if let (Some(run), Some(shard), Some(attempt)) = - (outcome.run, outcome.shard, outcome.attempt) - { - let key = ( - outcome.target.clone(), - outcome.configuration.clone(), - run, - shard, - ); - outcome.superseded = latest.get(&key).is_some_and(|last| attempt < *last); - } - } -} - -pub(crate) struct TestReportsRequest<'a> { - pub(crate) invocation: &'a Invocation, - pub(crate) workspace: &'a Path, - pub(crate) out: &'a mut dyn Write, - pub(crate) err: &'a mut dyn Write, - pub(crate) verb: WorkflowVerb, - pub(crate) bep: &'a Path, - pub(crate) planned_reports: &'a [PlannedReport], - pub(crate) failure_reports: &'a [PlannedReport], - pub(crate) results_reports: &'a [PlannedReport], - pub(crate) run_id: &'a str, - pub(crate) stdout_report: bool, - pub(crate) bazel_code: i32, - pub(crate) query_runner: &'a dyn QueryRunner, - pub(crate) results_complete: &'a mut bool, - pub(crate) actions: Option, - /// True when the run resolved to exactly `//...`, so every tracked source is in scope. - pub(crate) whole_repo: bool, -} - -/// Reads the output roots `bazel info` reports for this run, or workspace-only locations. -#[allow(clippy::disallowed_methods)] // info takes no build settings -pub(super) fn output_locations( - invocation: &Invocation, - workspace: &Path, - verb: WorkflowVerb, - query_runner: &dyn QueryRunner, -) -> OutputLocations { - let mut locations = OutputLocations::new(workspace); - let mut argv = dx_process::startup_argv(&invocation.bazel_startup_options); - argv.push("info".to_owned()); - if verb != WorkflowVerb::Coverage { - argv.push(invocation.build_mode().config_flag()); - } - argv.extend(invocation.raw_bazel_options().iter().cloned()); - argv.push("bazel-testlogs".to_owned()); - argv.push("execution_root".to_owned()); - argv.push("output_path".to_owned()); - if let Ok(result) = query_runner.run_info(&argv, workspace) - && result.code == Some(0) - { - locations.apply_bazel_info(&result.stdout); - } - locations -} - -/// The machine outcome for one BEP test status: Bazel stays authoritative. -/// -/// Passing after retry is distinct from a clean pass, and any other reported -/// status keeps its own lowercased token so new Bazel statuses stay distinct. -/// A missing status is unknown, never a fabricated zero value. -fn status_outcome(status: Option<&str>) -> String { - match status { - None => "unknown".to_owned(), - Some("PASSED") => "passed".to_owned(), - Some("FLAKY") => "passed_after_retry".to_owned(), - Some(other) => other.to_lowercase(), - } -} - -/// The machine outcome for one test result beside its target summary. -/// -/// Bazel reports per-attempt `testResult` statuses, so a retried attempt that -/// passes reads `PASSED` on its own line; only the `testSummary` -/// `overallStatus` says the target passed after retry. A passed attempt under -/// a `FLAKY` summary is `passed_after_retry`, never an ordinary pass. -fn result_outcome(status: Option<&str>, summary: Option<&str>) -> String { - if status == Some("PASSED") && summary == Some("FLAKY") { - return "passed_after_retry".to_owned(); - } - status_outcome(status) -} - -/// Indexes `testSummary` overall statuses by target and configuration. -fn summary_statuses(events: &dx_bep::TestEvents) -> BTreeMap<(&str, Option<&str>), &str> { - let mut statuses = BTreeMap::new(); - for summary in &events.summaries { - if let Some(status) = summary.overall_status.as_deref() { - statuses.insert( - (summary.label.as_str(), summary.configuration.as_deref()), - status, - ); - } - } - statuses -} - -/// Names one test result's file so a missing artifact is traceable to its run, shard, and attempt. -fn result_where(label: &str, name: &str, run: u32, shard: u32, attempt: u32) -> String { - format!("{name} for {label} (run {run}, shard {shard}, attempt {attempt})") -} - -const CASE_EVENT_LIMIT: usize = 1000; - -const CASE_MESSAGE_LIMIT: usize = 4096; - -const LOG_TAIL_LINES: usize = 40; - -const LOG_TAIL_BYTES: usize = 8192; - -/// The last `LOG_TAIL_LINES` lines of a test log. When those exceed `LOG_TAIL_BYTES`, the oldest -/// whole lines are dropped; a single oversize line is cut at a char boundary instead. -fn log_tail(bytes: &[u8]) -> Option { - let text = String::from_utf8_lossy(bytes); - let lines: Vec<&str> = text.lines().collect(); - let start = lines.len().saturating_sub(LOG_TAIL_LINES); - let mut tail = lines[start..].join("\n"); - if tail.len() > LOG_TAIL_BYTES { - let mut from = tail.len() - LOG_TAIL_BYTES; - while !tail.is_char_boundary(from) { - from += 1; - } - let at_line_start = tail.as_bytes()[from - 1] == b'\n'; - let window = &tail[from..]; - tail = match window.split_once('\n') { - _ if at_line_start => window.to_owned(), - Some((_, rest)) => rest.to_owned(), - None => window.to_owned(), - }; - } - (!tail.trim().is_empty()).then_some(tail) -} - -/// Adds one parsed test.xml's cases to the outcome's counts and bounded case list. -fn tally_cases(outcome: &mut TestOutcome, cases: &[JunitCase], cases_seen: &mut Vec) { - outcome.cases_total = Some(outcome.cases_total.unwrap_or(0) + cases.len() as u64); - for case in cases { - if case.failure.is_some() { - outcome.cases_failed = Some(outcome.cases_failed.unwrap_or(0) + 1); - } - if case.error.is_some() { - outcome.cases_error = Some(outcome.cases_error.unwrap_or(0) + 1); - } - if case.skipped.is_some() { - outcome.cases_skipped = Some(outcome.cases_skipped.unwrap_or(0) + 1); - } - if cases_seen.len() < CASE_EVENT_LIMIT || case.failure.is_some() || case.error.is_some() { - cases_seen.push(test_case(case)); - } else { - outcome.cases_omitted += 1; - } - } -} - -/// Points a result that did not pass at its test.log and keeps the log's tail. The undeclared -/// outputs zip, where traces and screenshots land, is listed beside it. -fn attach_log(outcome: &mut TestOutcome, result: &TestResultRecord, reader: &dyn ArtifactReader) { - if outcome.outcome == "passed" { - return; - } - if let Some(log) = result.files.iter().find(|file| file.name == "test.log") { - outcome.log_path = Some(log.exec_path.to_string_lossy().into_owned()); - outcome.log_tail = reader - .read_artifact(&log.exec_path) - .ok() - .and_then(|bytes| log_tail(&bytes)); - } - if let Some(zip) = result - .files - .iter() - .find(|file| file.name.ends_with("outputs.zip")) - { - outcome.outputs_path = Some(zip.exec_path.to_string_lossy().into_owned()); - } -} - -/// Counts every missing or invalid piece of required evidence and keeps the first one. -#[derive(Default)] -struct Evidence { - count: usize, - first: String, -} - -impl Evidence { - fn record(&mut self, problem: String) { - self.count += 1; - if self.first.is_empty() { - self.first = problem; - } - } -} - -/// The machine outcome of one native test case. -fn case_outcome(case: &JunitCase) -> &'static str { - if case.error.is_some() { - "error" - } else if case.failure.is_some() { - "failed" - } else if case.skipped.is_some() { - "skipped" - } else { - "passed" - } -} - -/// Cuts text to at most `limit` bytes on a character boundary. -fn bounded(text: &str, limit: usize) -> (String, bool) { - if text.len() <= limit { - return (text.to_owned(), false); - } - let mut end = limit; - while !text.is_char_boundary(end) { - end -= 1; - } - (text[..end].to_owned(), true) -} - -/// The reason a native report gave for one case, when it gave one. -fn case_message(case: &JunitCase) -> Option<&str> { - let note: &JunitMessage = case - .error - .as_ref() - .or(case.failure.as_ref()) - .or(case.skipped.as_ref())?; - let text = note - .message - .as_deref() - .filter(|message| !message.is_empty()) - .unwrap_or(¬e.text); - (!text.is_empty()).then_some(text) -} - -/// The first workspace-relative `path:line` in a failure text, such as `foo_test.go:42:` or `(Foo.java:17)`. -fn text_location(text: &str) -> Option<(String, u64)> { - text.split(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\'' | ',' | '<' | '>')) - .find_map(|token| { - let mut parts = token.split(':'); - let path = parts.next()?; - let line: u64 = parts.next()?.parse().ok()?; - let relative = !path.starts_with('/') && !path.starts_with('\\'); - let named = path - .rsplit('/') - .next() - .is_some_and(|name| name.contains('.')); - (relative && named && line > 0).then(|| (path.to_owned(), line)) - }) -} - -/// Where a case failed: the report's `file` and `line` attributes, else the first location in its failure text. -fn case_location(case: &JunitCase) -> (Option, Option) { - if case.file.is_some() { - return (case.file.clone(), case.line); - } - let note = case.error.as_ref().or(case.failure.as_ref()); - let found = note.and_then(|note| { - note.message - .as_deref() - .and_then(text_location) - .or_else(|| text_location(¬e.text)) - }); - match found { - Some((file, line)) => (Some(file), Some(line)), - None => (None, None), - } -} - -/// One case read from a result's native report. -fn test_case(case: &JunitCase) -> TestCase { - let message = case_message(case).map(|text| bounded(text, CASE_MESSAGE_LIMIT).0); - let (file, line) = case_location(case); - TestCase { - classname: case.classname.clone(), - name: case.name.clone(), - outcome: case_outcome(case).to_owned(), - message, - file, - line, - } -} - -/// The machine outcome for a target Bazel summarized without any test result. -fn summary_outcome(status: Option<&str>) -> String { - match status { - Some("FAILED_TO_BUILD") => "failed_to_build".to_owned(), - other => status_outcome(other), - } -} - -/// Accounts for undelivered events, summaries without results, unbuilt tests and skipped targets. -fn account_stream(events: &TestEvents, evidence: &mut Evidence, reported: &mut Vec) { - for gap in &events.gaps { - evidence.record(gap.describe()); - if gap.kind != "testResult" { - continue; - } - let Some(label) = gap.label.clone() else { - continue; - }; - reported.push(TestOutcome { - target: label, - configuration: gap.configuration.clone(), - outcome: gap.outcome().to_owned(), - run: gap.run, - shard: gap.shard, - attempt: gap.attempt, - artifacts_missing: 1, - ..TestOutcome::default() - }); - } - for summary in &events.summaries { - let has_result = events.results.iter().any(|result| { - result.label == summary.label && result.configuration == summary.configuration - }); - let has_gap = events.gaps.iter().any(|gap| { - gap.kind == "testResult" && gap.label.as_deref() == Some(summary.label.as_str()) - }); - if has_result || has_gap { - continue; - } - let status = summary.overall_status.as_deref(); - evidence.record(format!( - "no test result was reported for {} (summary {})", - summary.label, - status.unwrap_or("unknown") - )); - reported.push(TestOutcome { - target: summary.label.clone(), - configuration: summary.configuration.clone(), - outcome: summary_outcome(status), - status: summary.overall_status.clone(), - ..TestOutcome::default() - }); - } - for target in &events.unbuilt { - let summarized = events - .summaries - .iter() - .any(|summary| summary.label == target.label); - let has_result = events - .results - .iter() - .any(|result| result.label == target.label); - if summarized || has_result { - continue; - } - evidence.record(format!("{} failed to build", target.label)); - reported.push(TestOutcome { - target: target.label.clone(), - configuration: target.configuration.clone(), - outcome: "failed_to_build".to_owned(), - ..TestOutcome::default() - }); - } - for gap in &events.skipped { - let Some(label) = gap.label.clone() else { - continue; - }; - reported.push(TestOutcome { - target: label, - configuration: gap.configuration.clone(), - outcome: gap.outcome().to_owned(), - evidence_complete: true, - ..TestOutcome::default() - }); - } - for target in &events.filtered { - reported.push(TestOutcome { - target: target.label.clone(), - configuration: target.configuration.clone(), - outcome: "filtered".to_owned(), - evidence_complete: true, - ..TestOutcome::default() - }); - } - if !events.ended { - evidence.record("build events ended before Bazel's last event".to_owned()); - } -} - -const FILTERED_LABELS_SHOWN: usize = 3; - -fn filtered_notice( - output: OutputMode, - out: &mut dyn Write, - err: &mut dyn Write, - filtered: &[dx_bep::UnbuiltTest], -) -> Result<(), i32> { - if filtered.is_empty() { - return Ok(()); - } - let labels: Vec<&str> = filtered - .iter() - .take(FILTERED_LABELS_SHOWN) - .map(|target| target.label.as_str()) - .collect(); - let more = filtered.len().saturating_sub(labels.len()); - let mut message = format!( - "{} {} did not run: a test filter removed {}", - filtered.len(), - if filtered.len() == 1 { "test" } else { "tests" }, - labels.join(", ") - ); - if more > 0 { - message.push_str(&format!(" and {more} more")); - } - match output { - OutputMode::Json => { - emit_built( - out, - notice_event(&NoticeEvent::info(CODE_TESTS_FILTERED, message)), - )?; - } - OutputMode::Text { .. } => { - let _ = writeln!(err, "dx: tests_filtered: {message}"); - } - _ => {} - } - Ok(()) -} - -/// The results report: every outcome of the run, passed ones included. -fn results_document( - reported: &[TestOutcome], - complete: bool, - bazel_code: i32, - run_id: &str, -) -> String { - let results: Vec = reported - .iter() - .filter_map(|outcome| test_result_record(outcome).ok()) - .collect(); - let document = serde_json::json!({ - "record_version": 1, - "run_id": run_id, - "exit_code": bazel_code, - "complete": complete, - "results": results, - }); - serde_json::to_string_pretty(&document).unwrap_or_default() -} - -/// `testSummary` overall statuses keyed by target and configuration. -type Summaries<'a> = BTreeMap<(&'a str, Option<&'a str>), &'a str>; - -/// The outcome of one test result before any of its artifacts are read. -fn outcome_for(result: &TestResultRecord, summaries: &Summaries) -> TestOutcome { - let summary = summaries - .get(&(result.label.as_str(), result.configuration.as_deref())) - .copied(); - TestOutcome { - target: result.label.clone(), - configuration: result.configuration.clone(), - outcome: result_outcome(result.status.as_deref(), summary), - status: result.status.clone(), - cached: result.cached_locally, - run: Some(result.run), - shard: Some(result.shard), - attempt: Some(result.attempt), - duration_millis: result.duration_millis, - ..TestOutcome::default() - } -} - -/// Reads one result artifact, or counts it missing and records why. -fn read_or_record( - reader: &dyn ArtifactReader, - file: &TestResultFile, - where_: &str, - outcome: &mut TestOutcome, - evidence: &mut Evidence, -) -> Option> { - match reader.read_artifact(&file.exec_path) { - Ok(bytes) => Some(bytes), - Err(error) => { - outcome.artifacts_missing += 1; - evidence.record(format!( - "unreadable {where_} at {}: {error}", - stable_exec_path(&file.exec_path) - )); - None - } - } -} - -/// Parses one result's test.xml and stamps each case with the result's run and configuration. -fn parse_cases(result: &TestResultRecord, bytes: &[u8]) -> Result, ReportError> { - let shard = result.shard.saturating_sub(1); - let attempt = result.attempt.saturating_sub(1); - let mut cases = parse_test_xml(bytes, shard, attempt)?; - for case in &mut cases { - case.run = result.run.saturating_sub(1); - case.configuration = result.configuration.clone(); - } - Ok(cases) -} - -/// Why the results are incomplete: no artifacts at all, or any missing or invalid evidence. -fn evidence_gap(empty: bool, evidence: &Evidence, missing_text: &str) -> Option { - if empty { - Some(if evidence.first.is_empty() { - missing_text.to_owned() - } else { - evidence.first.clone() - }) - } else if evidence.count > 0 { - Some(unusable_detail(evidence.count, &evidence.first)) - } else { - None - } -} - -/// The aspect that writes coverage source manifests, as the build events name it. -const SOURCES_ASPECT_SUFFIX: &str = "coverage_sources.bzl%dx_coverage_sources_aspect"; - -/// The coverage source manifests a run reported, and every problem that left one missing. -#[derive(Default)] -struct Sources { - manifests: Vec, - missing: Vec, - invalid: Vec, - /// `bazel ` from the build events' `started` event. - collector: Option, - /// The checkout measured, when the workspace is a Git work tree. - candidate: Option, - /// Why the tracked-source check did not run, when it is not an accounting error. - tracked_unavailable: Option, -} - -/// Reads the coverage source manifests from the run's build events. -fn collect_sources(bep: &Path, workspace: &Path) -> Sources { - let mut sources = Sources { - collector: dx_bep::stream_build_tool_version(bep) - .ok() - .flatten() - .map(|version| format!("bazel {version}")), - ..Sources::default() - }; - let account = match collect_output_account(bep, SOURCES_OUTPUT_GROUP, workspace) { - Ok(account) => account, - Err((_, message)) => { - sources.missing.push(format!( - "coverage source manifests are unreadable: {message}" - )); - return sources; - } - }; - for target in &account.targets { - if !target - .aspect - .as_deref() - .is_some_and(|aspect| aspect.ends_with(SOURCES_ASPECT_SUFFIX)) - { - continue; - } - if !target.success || target.incomplete { - sources.missing.push(format!( - "{} did not report its coverage source manifest", - target.label - )); - continue; - } - for artifact in &target.artifacts { - match parse_source_manifest(&String::from_utf8_lossy(&artifact.bytes)) { - Ok(manifest) => sources.manifests.push(manifest), - Err(error) => sources.invalid.push(format!( - "invalid coverage source manifest {}: {error}", - stable_exec_path(&artifact.exec_path) - )), - } - } - } - sources -} - -/// Everything one run's results produced: report inputs, outcomes and the evidence gap. -#[derive(Default)] -struct Collected { - suites: Vec<(String, Vec)>, - lcov_documents: Vec, - sources: Sources, - reported: Vec, - gap: Option, - /// Sorted, deduplicated (label, language) pairs that ran under coverage without a collector. - no_collector: Vec<(String, String)>, -} - -/// Reads every result's test.xml into suites and outcomes for `dx test`. -fn collect_test_xml( - events: &TestEvents, - summaries: &Summaries, - reader: &dyn ArtifactReader, - mut evidence: Evidence, - err: &mut dyn Write, -) -> Collected { - let mut grouped: BTreeMap> = BTreeMap::new(); - let mut reported: Vec = Vec::new(); - for result in &events.results { - let mut outcome = outcome_for(result, summaries); - let mut cases_seen: Vec = Vec::new(); - let mut reports = 0usize; - for file in &result.files { - if file.name != "test.xml" { - continue; - } - reports += 1; - let where_ = result_where( - &result.label, - &file.name, - result.run, - result.shard, - result.attempt, - ); - let Some(bytes) = read_or_record(reader, file, &where_, &mut outcome, &mut evidence) - else { - continue; - }; - match parse_cases(result, &bytes) { - Ok(cases) => { - if std::str::from_utf8(&bytes).is_err() { - let _ = writeln!( - err, - "dx: warning: {where_} has invalid UTF-8; replaced invalid bytes" - ); - } - outcome.artifacts_collected += 1; - tally_cases(&mut outcome, &cases, &mut cases_seen); - grouped - .entry(result.label.clone()) - .or_default() - .extend(cases); - } - Err(error) => { - outcome.artifacts_invalid += 1; - evidence.record(format!( - "invalid {where_} at {}: {error}", - stable_exec_path(&file.exec_path) - )); - } - } - } - if reports == 0 { - outcome.artifacts_missing += 1; - evidence.record(format!( - "{} was not reported", - result_where( - &result.label, - "test.xml", - result.run, - result.shard, - result.attempt - ) - )); - } - outcome.evidence_complete = outcome.artifacts_collected > 0 - && outcome.artifacts_missing == 0 - && outcome.artifacts_invalid == 0; - if outcome.evidence_complete - && outcome.outcome == "passed" - && outcome.cases_total == Some(0) - { - outcome.outcome = "no_cases".to_owned(); - } - attach_log(&mut outcome, result, reader); - outcome.cases = cases_seen; - reported.push(outcome); - } - account_stream(events, &mut evidence, &mut reported); - let exempt = - events.results.is_empty() && (!events.skipped.is_empty() || !events.filtered.is_empty()); - let gap = evidence_gap( - grouped.is_empty() && !exempt, - &evidence, - "no test.xml artifacts were reported", - ); - Collected { - suites: grouped.into_iter().collect(), - reported, - gap, - ..Collected::default() - } -} - -/// Reads every result's final coverage output into LCOV documents and outcomes for `dx coverage`. -fn collect_lcov( - events: &TestEvents, - summaries: &Summaries, - reader: &dyn ArtifactReader, - mut evidence: Evidence, - sources: Sources, -) -> Collected { - let finals = final_coverage_attempts(&events.results); - let mut lcov_documents: Vec = Vec::new(); - let mut reported: Vec = Vec::new(); - for result in &events.results { - let mut required = Vec::new(); - for file in &result.files { - if !is_coverage_output(&file.name) { - continue; - } - let key = ( - result.label.as_str(), - result.run, - result.shard, - file.name.as_str(), - ); - let Some(final_attempt) = finals.get(&key) else { - continue; - }; - if result.attempt < *final_attempt { - continue; - } - required.push(file); - } - if result.files.iter().any(is_coverage_file) && required.is_empty() { - continue; - } - let mut outcome = outcome_for(result, summaries); - for file in required { - let where_ = result_where( - &result.label, - &file.name, - result.run, - result.shard, - result.attempt, - ); - let Some(bytes) = read_or_record(reader, file, &where_, &mut outcome, &mut evidence) - else { - continue; - }; - match validate_lcov(&bytes) { - Ok(()) => { - outcome.artifacts_collected += 1; - lcov_documents.push(String::from_utf8_lossy(&bytes).into_owned()); - } - Err(error) => { - if bytes.iter().all(|b| b.is_ascii_whitespace()) { - outcome.artifacts_collected += 1; - continue; - } - outcome.artifacts_invalid += 1; - evidence.record(format!( - "invalid {where_} at {}: {error}", - stable_exec_path(&file.exec_path) - )); - } - } - } - outcome.evidence_complete = (outcome.artifacts_collected > 0 - && outcome.artifacts_missing == 0 - && outcome.artifacts_invalid == 0) - || !result.files.iter().any(is_coverage_file); - if outcome.outcome != "passed" { - // Best effort: coverage completeness stays about the LCOV files only. - let mut cases_seen: Vec = Vec::new(); - for file in result.files.iter().filter(|file| file.name == "test.xml") { - let Ok(bytes) = reader.read_artifact(&file.exec_path) else { - continue; - }; - if let Ok(cases) = parse_cases(result, &bytes) { - tally_cases(&mut outcome, &cases, &mut cases_seen); - } - } - outcome.cases = cases_seen; - attach_log(&mut outcome, result, reader); - } - reported.push(outcome); - } - account_stream(events, &mut evidence, &mut reported); - for problem in &sources.missing { - evidence.record(problem.clone()); - } - let no_collector = no_collector_labels(events); - // A run of only collector-less tests has no LCOV by design; the - // no_coverage_collector verdict names the cause instead of a missing-artifact gap. - let gap = evidence_gap( - lcov_documents.is_empty() && no_collector.is_empty(), - &evidence, - "no coverage.dat artifacts were reported", - ); - Collected { - lcov_documents, - sources, - reported, - gap, - no_collector, - ..Collected::default() - } -} - -/// Tests tagged as having no coverage collector that produced a result, as (label, language), once each. -fn no_collector_labels(events: &TestEvents) -> Vec<(String, String)> { - let mut tests: Vec<(String, String)> = events - .results - .iter() - .filter_map(|result| { - let language = events.no_coverage_collector.get(&result.label)?; - Some((result.label.clone(), language.clone())) - }) - .collect(); - tests.sort(); - tests.dedup(); - tests -} - -/// Reports tests that ran under coverage with no collector; true when none should fail the run. -/// Without `strict` they are skipped with one summary line per language. -fn announce_no_collector( - output: OutputMode, - out: &mut dyn Write, - err: &mut dyn Write, - tests: &[(String, String)], - strict: bool, -) -> Result { - if !strict { - let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); - for (_, language) in tests { - *counts.entry(language.as_str()).or_default() += 1; - } - for (language, count) in counts { - let noun = if count == 1 { "test" } else { "tests" }; - let message = - format!("skipped {count} {language} {noun}: no coverage collector (#7298)"); - if output == OutputMode::Json { - emit_built( - out, - notice_event(&NoticeEvent::info(CODE_NO_COVERAGE_COLLECTOR, message)), - )?; - } else { - let _ = writeln!(err, "dx: {message}"); - } - } - return Ok(true); - } - for (label, _) in tests { - let message = format!("no coverage collector for {label}"); - if output == OutputMode::Json { - emit_built( - out, - dx_output::error_event(CODE_NO_COVERAGE_COLLECTOR, &message, None, None, None), - )?; - } else { - let _ = writeln!(err, "dx: {CODE_NO_COVERAGE_COLLECTOR}: {message}"); - } - } - Ok(tests.is_empty()) -} - -/// Prints each outcome in the run's output mode and counts the ones that were not superseded. -fn emit_outcomes( - output: OutputMode, - out: &mut dyn Write, - err: &mut dyn Write, - reported: &[TestOutcome], - filtered: &[dx_bep::UnbuiltTest], -) -> Result { - let mut counts = TestCounts::default(); - if output == OutputMode::Json { - for outcome in reported { - let passed = outcome.evidence_complete - && matches!( - outcome.outcome.as_str(), - "passed" | "passed_after_retry" | "no_cases" - ); - if !outcome.superseded && passed { - counts.passed += 1; - if outcome.cached == Some(true) { - counts.cached += 1; - } - if outcome.outcome == "no_cases" { - counts.no_cases += 1; - } - } else if !outcome.superseded && is_failed(&outcome.outcome) { - counts.failed += 1; - } else if !outcome.superseded - && matches!(outcome.outcome.as_str(), "skipped" | "filtered") - { - counts.skipped += 1; - } - if outcome.evidence_complete - && (outcome.outcome == "passed" - || (outcome.outcome == "no_cases" && !outcome.superseded)) - { - continue; - } - emit_built(out, test_outcome_event(outcome))?; - } - filtered_notice(output, out, err, filtered)?; - } else if matches!(output, OutputMode::Text { .. }) { - filtered_notice(output, out, err, filtered)?; - for outcome in reported { - if outcome.outcome == "passed_after_retry" { - let attempt = outcome - .attempt - .map(|attempt| format!(" (attempt {attempt})")) - .unwrap_or_default(); - let _ = writeln!( - err, - "dx: flaky success (passed after retry): {}{attempt}", - outcome.target - ); - } - if !outcome.superseded && is_failed(&outcome.outcome) { - write_failed_line(err, outcome); - } - } - } - Ok(counts) -} - -/// Most failing case names one text-mode failure line lists. -const TEXT_CASE_LIMIT: usize = 3; - -/// Prints `dx: failed: : (log: )` for one failed result. -fn write_failed_line(err: &mut dyn Write, outcome: &TestOutcome) { - let failing: Vec<&str> = outcome - .cases - .iter() - .filter(|case| case.outcome == "failed" || case.outcome == "error") - .map(|case| case.name.as_str()) - .collect(); - let mut line = format!("dx: failed: {}", outcome.target); - if !failing.is_empty() { - line.push_str(": "); - line.push_str(&failing[..failing.len().min(TEXT_CASE_LIMIT)].join(", ")); - if failing.len() > TEXT_CASE_LIMIT { - line.push_str(&format!(" and {} more", failing.len() - TEXT_CASE_LIMIT)); - } - } - if let Some(log) = &outcome.log_path { - line.push_str(&format!(" (log: {log})")); - } - let _ = writeln!(err, "{line}"); -} - -/// Writes reports to their destinations and announces each one in the run's output mode. -struct ReportSink<'a> { - out: &'a mut dyn Write, - err: &'a mut dyn Write, - output: OutputMode, - workspace: &'a Path, - stdout_report: bool, -} - -impl ReportSink<'_> { - /// Writes one named report and announces it; false when the write failed and was reported. - fn write( - &mut self, - name: &str, - destination: &Destination, - document: &str, - complete: bool, - ) -> Result { - if let Err(error) = write_report_document(self.out, self.workspace, destination, document) { - self.failed(&format!("failed to write {name} report to {error}"), None)?; - return Ok(false); - } - if self.output == OutputMode::Json { - emit_built( - self.out, - report_event(name, destination.display(), complete), - )?; - } else if matches!(self.output, OutputMode::Text { .. }) && !self.stdout_report { - let _ = writeln!( - self.out, - "Wrote {name} report to {}.", - destination.display() - ); - } - Ok(true) - } - - /// Reports one report that could not be produced, with the next step for its cause. - fn failed(&mut self, detail: &str, next: Option<&str>) -> Result<(), i32> { - report_failed_with_next(self.out, self.err, self.output, detail, next) - } -} - -/// The verb's report document, or why it could not be rendered. -fn render_report( - verb: WorkflowVerb, - suites: &[(String, Vec)], - lcov_documents: &[String], - detail: &str, - format: StandardFormat, - sources_document: Option<&str>, -) -> Result { - if format == StandardFormat::Sources { - return match sources_document { - Some(document) if detail.is_empty() => Ok(document.to_owned()), - _ => Err(if detail.is_empty() { - "no coverage source account is available".to_owned() - } else { - detail.to_owned() - }), - }; - } - match verb { - WorkflowVerb::Test => render_junit(suites).map_err(|error| error.to_string()), - WorkflowVerb::Coverage => { - if lcov_documents.is_empty() { - Err(detail.to_owned()) - } else { - let mut combined = lcov_documents.join("\n"); - if !combined.ends_with('\n') { - combined.push('\n'); - } - Ok(combined) - } - } - _ => Err(detail.to_owned()), // LCOV_EXCL_LINE - reason: defensive arm, issue: 1055, policy: docs/cli/commands/build-test-coverage.md - } -} - -/// Reports why the results are incomplete, when evidence was missing or invalid. -fn announce_incomplete( - output: OutputMode, - out: &mut dyn Write, - err: &mut dyn Write, - detail: &str, -) -> Result<(), i32> { - if detail.is_empty() { - return Ok(()); - } - if output == OutputMode::Json { - emit_built( - out, - dx_output::error_event("incomplete_results", detail, None, None, None), - )?; - } else { - let _ = writeln!(err, "dx: incomplete_results: {detail}"); - } - Ok(()) -} - -/// Checks the cell's owned-source line coverage against `--min-coverage`; true when there is no minimum. -fn coverage_threshold( - minimum: Option, - account: Option<&CellAccount>, - sources: &Sources, - output: OutputMode, - out: &mut dyn Write, - err: &mut dyn Write, -) -> Result { - let Some(minimum) = minimum else { - return Ok(true); - }; - let Some(account) = account else { - return Ok(false); - }; - let covered = account.covered_lines; - let eligible = account.eligible_lines; - let errors = sources.invalid.len() + account.errors.len(); - let threshold_ok = errors == 0 && account.meets(minimum); - let lines = if eligible > 0 { - let percent = 100.0 * covered as f64 / eligible as f64; - format!("coverage {percent:.2}% ({covered}/{eligible} lines)") - } else { - format!("coverage ({covered}/{eligible} lines)") - }; - let cell = &account.cell; - let summary = if errors > 0 { - let noun = if errors == 1 { "gap" } else { "gaps" }; - format!( - "{lines} in package {cell} has {errors} coverage source {noun} (see {CODE_COVERAGE_SOURCES_INCOMPLETE}); minimum {minimum}% (coverage_below_minimum)" - ) - } else if eligible == 0 { - format!( - "no executable lines in the owned sources of package {cell} (coverage_below_minimum)" - ) - } else if threshold_ok { - format!("{lines} meets minimum {minimum}% in package {cell}") - } else { - format!("{lines} below minimum {minimum}% in package {cell} (coverage_below_minimum)") - }; - if output == OutputMode::Json { - if !threshold_ok { - emit_built( - out, - dx_output::error_event(CODE_COVERAGE_BELOW_MINIMUM, &summary, None, None, None), - )?; - } - } else { - let _ = writeln!(err, "dx: {summary}"); - } - Ok(threshold_ok) -} - -/// How many files the text coverage table lists. -const WORST_FILES: usize = 10; - -/// Prints the total coverage line (when no minimum prints it) and the least covered files. -fn coverage_summary( - account: &CellAccount, - show_total: bool, - output: OutputMode, - err: &mut dyn Write, -) { - let OutputMode::Text { quiet: false } = output else { - return; - }; - let covered = account.covered_lines; - let eligible = account.eligible_lines; - if show_total { - if eligible > 0 { - let percent = 100.0 * covered as f64 / eligible as f64; - let _ = writeln!( - err, - "dx: coverage {percent:.1}% ({covered}/{eligible} lines)" - ); - } else { - let _ = writeln!(err, "dx: coverage (0/0 lines)"); - } - } - let mut worst: Vec<(&String, u64, u64)> = account - .file_lines - .iter() - .filter(|(_, (covered, eligible))| covered < eligible) - .map(|(path, (covered, eligible))| (path, *covered, *eligible)) - .collect(); - worst.sort_by(|a, b| { - (a.1 * b.2) - .cmp(&(b.1 * a.2)) - .then((b.2 - b.1).cmp(&(a.2 - a.1))) - .then(a.0.cmp(b.0)) - }); - if worst.is_empty() { - return; - } - let _ = writeln!(err, "dx: least covered files:"); - for (path, covered, eligible) in worst.into_iter().take(WORST_FILES) { - let percent = 100.0 * covered as f64 / eligible as f64; - let _ = writeln!(err, " {percent:5.1}% {covered}/{eligible} {path}"); - } -} - -/// Accounts every owned source of the run against the merged coverage. -fn account_sources( - sources: &Sources, - lcov_documents: &[String], - workspace: &Path, -) -> dx_lcov::CellAccount { - dx_lcov::account(&sources.manifests, lcov_documents, &|path| { - std::fs::read(workspace.join(path)) - .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) - .map_err(|error| dx_lcov::LcovError::Io { - message: format!("{path}: {error}"), - }) - }) -} - -/// The `sources` report: the cell's owned sources with dispositions and the production sources the report never saw. -fn sources_report(sources: &Sources, account: &dx_lcov::CellAccount) -> String { - let owned: std::collections::BTreeMap<&str, &str> = account - .files - .iter() - .map(|(path, disposition)| { - let name = match disposition { - dx_lcov::Disposition::Eligible => "eligible", - dx_lcov::Disposition::Support => "support", - dx_lcov::Disposition::Excluded => "excluded", - dx_lcov::Disposition::Unsupported => "unsupported", - }; - (path.as_str(), name) - }) - .chain(account.absent.iter().map(|path| (path.as_str(), "absent"))) - .collect(); - let errors: Vec<&String> = sources.invalid.iter().chain(&account.errors).collect(); - let candidate = sources - .candidate - .as_ref() - .map(|found| serde_json::json!({"sha": found.sha, "dirty": found.dirty})); - let document = serde_json::json!({ - "record_version": 2, - "cell": account.cell, - "candidate": candidate, - "collector": sources.collector, - "owned": owned, - "absent": account.absent, - "orphans": account.orphans, - "errors": errors, - }); - serde_json::to_string_pretty(&document).unwrap_or_default() + "\n" -} - -/// Prints the account's counts and every gap; true when none. -/// With `enforce` (`--min-coverage`) each gap is a JSON `error` event instead of a warning notice. -fn coverage_sources( - sources: &Sources, - account: &dx_lcov::CellAccount, - output: OutputMode, - enforce: bool, - out: &mut dyn Write, - err: &mut dyn Write, -) -> Result { - let summary = account.summary(); - let errors: Vec<&String> = sources.invalid.iter().chain(&account.errors).collect(); - let info: fn(&str, String) -> NoticeEvent = NoticeEvent::info; - let warning: fn(&str, String) -> NoticeEvent = NoticeEvent::warning; - let unavailable = sources - .tracked_unavailable - .iter() - .map(|why| (warning, CODE_COVERAGE_SOURCES, why)); - let notices = std::iter::once((info, CODE_COVERAGE_SOURCES, &summary)) - .chain(unavailable) - .chain( - errors - .iter() - .map(|message| (warning, CODE_COVERAGE_SOURCES_INCOMPLETE, *message)), - ); - for (notice, code, message) in notices { - match output { - OutputMode::Json if enforce && code == CODE_COVERAGE_SOURCES_INCOMPLETE => { - emit_built(out, dx_output::error_event(code, message, None, None, None))?; - } - OutputMode::Json => { - emit_built(out, notice_event(¬ice(code, message.clone())))?; - } - OutputMode::Text { .. } if code == CODE_COVERAGE_SOURCES => { - let _ = writeln!(err, "dx: {message}"); - } - OutputMode::Text { .. } => { - let _ = writeln!(err, "dx: {code}: {message}"); - } - _ => {} - } - } - Ok(errors.is_empty()) -} - -/// Emits the cell's `coverage` event in JSON mode. -fn coverage_event( - sources: &Sources, - account: &CellAccount, - minimum: Option, - output: OutputMode, - out: &mut dyn Write, -) -> Result<(), i32> { - if output != OutputMode::Json { - return Ok(()); - } - let errors = (sources.invalid.len() + account.errors.len()) as u64; - let event = dx_output::CoverageEvent { - cell: account.cell.clone(), - covered_lines: account.covered_lines, - eligible_lines: account.eligible_lines, - raw_lines: account.raw_lines, - excluded_lines: account.excluded_lines, - eligible_files: account.eligible_files, - support_files: account.support_files, - excluded_files: account.excluded_files, - unsupported_files: account.unsupported_files, - minimum, - passed: errors == 0 && account.meets(minimum.unwrap_or(0)), - errors, - dx_version: dx_adopt::DX_VERSION.to_owned(), - sha: sources.candidate.as_ref().map(|found| found.sha.clone()), - dirty: sources.candidate.as_ref().is_some_and(|found| found.dirty), - collector: sources.collector.clone(), - }; - emit_built(out, dx_output::coverage_event(&event)) -} - -/// Reads the run's test events; a stream Bazel cut short is a problem only when Bazel failed too. -fn read_events( - invocation: &Invocation, - workspace: &Path, - verb: WorkflowVerb, - bep: &Path, - bazel_code: i32, - query_runner: &dyn QueryRunner, -) -> Result<(TestEvents, Option), (&'static str, String)> { - let file = match std::fs::File::open(bep) { - Ok(file) => file, - Err(error) if bazel_code != 0 => { - return Ok(( - TestEvents::default(), - Some(format!("build events are unavailable: {error}")), - )); - } - Err(error) => { - return Err(( - CODE_UNREADABLE_BEP, - format!("failed to read build events: {error}"), - )); - } - }; - match collect_test_events(BufReader::new(file), || { - Some(output_locations(invocation, workspace, verb, query_runner)) - }) { - Ok(events) => match events.problem.clone() { - Some(problem) if bazel_code == 0 => { - Err((CODE_INVALID_BEP, format!("invalid build events: {problem}"))) - } - Some(problem) => Ok((events, Some(format!("build events end early: {problem}")))), - None => Ok((events, None)), - }, - Err(error) if bazel_code != 0 => Ok(( - TestEvents::default(), - Some(format!("invalid build events: {error}")), - )), - Err(error) => Err((CODE_INVALID_BEP, format!("invalid build events: {error}"))), - } -} +mod account; +use account::BAZEL_TESTS_FAILED; +use account::*; +pub(crate) use account::{TestReportsRequest, output_locations}; +mod collect; +use collect::*; +mod report; +use report::*; pub(crate) fn execute_test_reports(request: TestReportsRequest<'_>) -> i32 { let TestReportsRequest { diff --git a/cli/cli/src/exec/test_reports/account.rs b/cli/cli/src/exec/test_reports/account.rs new file mode 100644 index 0000000000..51f1bd8158 --- /dev/null +++ b/cli/cli/src/exec/test_reports/account.rs @@ -0,0 +1,625 @@ +use super::*; + +/// Returns one exec path spelled without the build directory, so a report reads the same on every host. +pub(super) fn stable_exec_path(exec_path: &Path) -> String { + let text = dx_path::posix(exec_path); + if let Some(out) = text.find("/bazel-testlogs/") { + return text[out + "/bazel-testlogs/".len()..].to_owned(); + } + if let Some(out) = text.find("/testlogs/") { + return text[out + "/testlogs/".len()..].to_owned(); + } + if let Some(out) = text.find("/bazel-out/") { + let rest = &text[out + "/bazel-out/".len()..]; + return match rest.find("/bin/") { + Some(bin) => rest[bin + "/bin/".len()..].to_owned(), + None => rest.to_owned(), + }; + } + if let Some(bin) = text.find("/bin/") { + return text[bin + "/bin/".len()..].to_owned(); + } + exec_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or(text) +} + +/// Bazel's exit code when every test built and ran but some failed. +pub(super) const BAZEL_TESTS_FAILED: i32 = 3; + +/// Whether a test outcome counts as a failed test. +pub(super) fn is_failed(outcome: &str) -> bool { + matches!(outcome, "failed" | "timeout") +} + +pub(super) fn unusable_detail(count: usize, first: &str) -> String { + if count > 1 { + format!("{count} missing or invalid results: {first}") + } else { + first.to_owned() + } +} + +/// True for the names Bazel reports a test's coverage result under. +pub(super) fn is_coverage_output(name: &str) -> bool { + name == "coverage.dat" || name == "test.lcov" +} + +/// True for result files that carry coverage evidence. +pub(super) fn is_coverage_file(file: &TestResultFile) -> bool { + is_coverage_output(&file.name) +} + +/// The latest attempt Bazel reported for each result's named coverage output. +pub(super) fn final_coverage_attempts( + results: &[TestResultRecord], +) -> BTreeMap<(&str, u32, u32, &str), u32> { + let mut finals = BTreeMap::new(); + for result in results { + for file in &result.files { + if !is_coverage_output(&file.name) { + continue; + } + let key = ( + result.label.as_str(), + result.run, + result.shard, + file.name.as_str(), + ); + let seen = finals.entry(key).or_insert(0); + *seen = (*seen).max(result.attempt); + } + } + finals +} + +/// Marks each outcome a later attempt of the same target, configuration, run and shard replaced. +pub(super) fn mark_superseded_attempts(reported: &mut [TestOutcome]) { + let mut latest: BTreeMap<(String, Option, u32, u32), u32> = BTreeMap::new(); + for outcome in reported.iter() { + if let (Some(run), Some(shard), Some(attempt)) = + (outcome.run, outcome.shard, outcome.attempt) + { + let key = ( + outcome.target.clone(), + outcome.configuration.clone(), + run, + shard, + ); + let seen = latest.entry(key).or_insert(attempt); + *seen = (*seen).max(attempt); + } + } + for outcome in reported.iter_mut() { + if let (Some(run), Some(shard), Some(attempt)) = + (outcome.run, outcome.shard, outcome.attempt) + { + let key = ( + outcome.target.clone(), + outcome.configuration.clone(), + run, + shard, + ); + outcome.superseded = latest.get(&key).is_some_and(|last| attempt < *last); + } + } +} + +pub(crate) struct TestReportsRequest<'a> { + pub(crate) invocation: &'a Invocation, + pub(crate) workspace: &'a Path, + pub(crate) out: &'a mut dyn Write, + pub(crate) err: &'a mut dyn Write, + pub(crate) verb: WorkflowVerb, + pub(crate) bep: &'a Path, + pub(crate) planned_reports: &'a [PlannedReport], + pub(crate) failure_reports: &'a [PlannedReport], + pub(crate) results_reports: &'a [PlannedReport], + pub(crate) run_id: &'a str, + pub(crate) stdout_report: bool, + pub(crate) bazel_code: i32, + pub(crate) query_runner: &'a dyn QueryRunner, + pub(crate) results_complete: &'a mut bool, + pub(crate) actions: Option, + /// True when the run resolved to exactly `//...`, so every tracked source is in scope. + pub(crate) whole_repo: bool, +} + +/// Reads the output roots `bazel info` reports for this run, or workspace-only locations. +#[allow(clippy::disallowed_methods)] // info takes no build settings +pub(crate) fn output_locations( + invocation: &Invocation, + workspace: &Path, + verb: WorkflowVerb, + query_runner: &dyn QueryRunner, +) -> OutputLocations { + let mut locations = OutputLocations::new(workspace); + let mut argv = dx_process::startup_argv(&invocation.bazel_startup_options); + argv.push("info".to_owned()); + if verb != WorkflowVerb::Coverage { + argv.push(invocation.build_mode().config_flag()); + } + argv.extend(invocation.raw_bazel_options().iter().cloned()); + argv.push("bazel-testlogs".to_owned()); + argv.push("execution_root".to_owned()); + argv.push("output_path".to_owned()); + if let Ok(result) = query_runner.run_info(&argv, workspace) + && result.code == Some(0) + { + locations.apply_bazel_info(&result.stdout); + } + locations +} + +/// The machine outcome for one BEP test status: Bazel stays authoritative. +/// +/// Passing after retry is distinct from a clean pass, and any other reported +/// status keeps its own lowercased token so new Bazel statuses stay distinct. +/// A missing status is unknown, never a fabricated zero value. +pub(super) fn status_outcome(status: Option<&str>) -> String { + match status { + None => "unknown".to_owned(), + Some("PASSED") => "passed".to_owned(), + Some("FLAKY") => "passed_after_retry".to_owned(), + Some(other) => other.to_lowercase(), + } +} + +/// The machine outcome for one test result beside its target summary. +/// +/// Bazel reports per-attempt `testResult` statuses, so a retried attempt that +/// passes reads `PASSED` on its own line; only the `testSummary` +/// `overallStatus` says the target passed after retry. A passed attempt under +/// a `FLAKY` summary is `passed_after_retry`, never an ordinary pass. +pub(super) fn result_outcome(status: Option<&str>, summary: Option<&str>) -> String { + if status == Some("PASSED") && summary == Some("FLAKY") { + return "passed_after_retry".to_owned(); + } + status_outcome(status) +} + +/// Indexes `testSummary` overall statuses by target and configuration. +pub(super) fn summary_statuses( + events: &dx_bep::TestEvents, +) -> BTreeMap<(&str, Option<&str>), &str> { + let mut statuses = BTreeMap::new(); + for summary in &events.summaries { + if let Some(status) = summary.overall_status.as_deref() { + statuses.insert( + (summary.label.as_str(), summary.configuration.as_deref()), + status, + ); + } + } + statuses +} + +/// Names one test result's file so a missing artifact is traceable to its run, shard, and attempt. +pub(super) fn result_where(label: &str, name: &str, run: u32, shard: u32, attempt: u32) -> String { + format!("{name} for {label} (run {run}, shard {shard}, attempt {attempt})") +} + +pub(super) const CASE_EVENT_LIMIT: usize = 1000; + +pub(super) const CASE_MESSAGE_LIMIT: usize = 4096; + +pub(super) const LOG_TAIL_LINES: usize = 40; + +pub(super) const LOG_TAIL_BYTES: usize = 8192; + +/// The last `LOG_TAIL_LINES` lines of a test log. When those exceed `LOG_TAIL_BYTES`, the oldest +/// whole lines are dropped; a single oversize line is cut at a char boundary instead. +pub(super) fn log_tail(bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(bytes); + let lines: Vec<&str> = text.lines().collect(); + let start = lines.len().saturating_sub(LOG_TAIL_LINES); + let mut tail = lines[start..].join("\n"); + if tail.len() > LOG_TAIL_BYTES { + let mut from = tail.len() - LOG_TAIL_BYTES; + while !tail.is_char_boundary(from) { + from += 1; + } + let at_line_start = tail.as_bytes()[from - 1] == b'\n'; + let window = &tail[from..]; + tail = match window.split_once('\n') { + _ if at_line_start => window.to_owned(), + Some((_, rest)) => rest.to_owned(), + None => window.to_owned(), + }; + } + (!tail.trim().is_empty()).then_some(tail) +} + +/// Adds one parsed test.xml's cases to the outcome's counts and bounded case list. +pub(super) fn tally_cases( + outcome: &mut TestOutcome, + cases: &[JunitCase], + cases_seen: &mut Vec, +) { + outcome.cases_total = Some(outcome.cases_total.unwrap_or(0) + cases.len() as u64); + for case in cases { + if case.failure.is_some() { + outcome.cases_failed = Some(outcome.cases_failed.unwrap_or(0) + 1); + } + if case.error.is_some() { + outcome.cases_error = Some(outcome.cases_error.unwrap_or(0) + 1); + } + if case.skipped.is_some() { + outcome.cases_skipped = Some(outcome.cases_skipped.unwrap_or(0) + 1); + } + if cases_seen.len() < CASE_EVENT_LIMIT || case.failure.is_some() || case.error.is_some() { + cases_seen.push(test_case(case)); + } else { + outcome.cases_omitted += 1; + } + } +} + +/// Points a result that did not pass at its test.log and keeps the log's tail. The undeclared +/// outputs zip, where traces and screenshots land, is listed beside it. +pub(super) fn attach_log( + outcome: &mut TestOutcome, + result: &TestResultRecord, + reader: &dyn ArtifactReader, +) { + if outcome.outcome == "passed" { + return; + } + if let Some(log) = result.files.iter().find(|file| file.name == "test.log") { + outcome.log_path = Some(log.exec_path.to_string_lossy().into_owned()); + outcome.log_tail = reader + .read_artifact(&log.exec_path) + .ok() + .and_then(|bytes| log_tail(&bytes)); + } + if let Some(zip) = result + .files + .iter() + .find(|file| file.name.ends_with("outputs.zip")) + { + outcome.outputs_path = Some(zip.exec_path.to_string_lossy().into_owned()); + } +} + +/// Counts every missing or invalid piece of required evidence and keeps the first one. +#[derive(Default)] +pub(super) struct Evidence { + pub(super) count: usize, + pub(super) first: String, +} + +impl Evidence { + pub(super) fn record(&mut self, problem: String) { + self.count += 1; + if self.first.is_empty() { + self.first = problem; + } + } +} + +/// The machine outcome of one native test case. +pub(super) fn case_outcome(case: &JunitCase) -> &'static str { + if case.error.is_some() { + "error" + } else if case.failure.is_some() { + "failed" + } else if case.skipped.is_some() { + "skipped" + } else { + "passed" + } +} + +/// Cuts text to at most `limit` bytes on a character boundary. +pub(super) fn bounded(text: &str, limit: usize) -> (String, bool) { + if text.len() <= limit { + return (text.to_owned(), false); + } + let mut end = limit; + while !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_owned(), true) +} + +/// The reason a native report gave for one case, when it gave one. +pub(super) fn case_message(case: &JunitCase) -> Option<&str> { + let note: &JunitMessage = case + .error + .as_ref() + .or(case.failure.as_ref()) + .or(case.skipped.as_ref())?; + let text = note + .message + .as_deref() + .filter(|message| !message.is_empty()) + .unwrap_or(¬e.text); + (!text.is_empty()).then_some(text) +} + +/// The first workspace-relative `path:line` in a failure text, such as `foo_test.go:42:` or `(Foo.java:17)`. +pub(super) fn text_location(text: &str) -> Option<(String, u64)> { + text.split(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\'' | ',' | '<' | '>')) + .find_map(|token| { + let mut parts = token.split(':'); + let path = parts.next()?; + let line: u64 = parts.next()?.parse().ok()?; + let relative = !path.starts_with('/') && !path.starts_with('\\'); + let named = path + .rsplit('/') + .next() + .is_some_and(|name| name.contains('.')); + (relative && named && line > 0).then(|| (path.to_owned(), line)) + }) +} + +/// Where a case failed: the report's `file` and `line` attributes, else the first location in its failure text. +pub(super) fn case_location(case: &JunitCase) -> (Option, Option) { + if case.file.is_some() { + return (case.file.clone(), case.line); + } + let note = case.error.as_ref().or(case.failure.as_ref()); + let found = note.and_then(|note| { + note.message + .as_deref() + .and_then(text_location) + .or_else(|| text_location(¬e.text)) + }); + match found { + Some((file, line)) => (Some(file), Some(line)), + None => (None, None), + } +} + +/// One case read from a result's native report. +pub(super) fn test_case(case: &JunitCase) -> TestCase { + let message = case_message(case).map(|text| bounded(text, CASE_MESSAGE_LIMIT).0); + let (file, line) = case_location(case); + TestCase { + classname: case.classname.clone(), + name: case.name.clone(), + outcome: case_outcome(case).to_owned(), + message, + file, + line, + } +} + +/// The machine outcome for a target Bazel summarized without any test result. +pub(super) fn summary_outcome(status: Option<&str>) -> String { + match status { + Some("FAILED_TO_BUILD") => "failed_to_build".to_owned(), + other => status_outcome(other), + } +} + +/// Accounts for undelivered events, summaries without results, unbuilt tests and skipped targets. +pub(super) fn account_stream( + events: &TestEvents, + evidence: &mut Evidence, + reported: &mut Vec, +) { + for gap in &events.gaps { + evidence.record(gap.describe()); + if gap.kind != "testResult" { + continue; + } + let Some(label) = gap.label.clone() else { + continue; + }; + reported.push(TestOutcome { + target: label, + configuration: gap.configuration.clone(), + outcome: gap.outcome().to_owned(), + run: gap.run, + shard: gap.shard, + attempt: gap.attempt, + artifacts_missing: 1, + ..TestOutcome::default() + }); + } + for summary in &events.summaries { + let has_result = events.results.iter().any(|result| { + result.label == summary.label && result.configuration == summary.configuration + }); + let has_gap = events.gaps.iter().any(|gap| { + gap.kind == "testResult" && gap.label.as_deref() == Some(summary.label.as_str()) + }); + if has_result || has_gap { + continue; + } + let status = summary.overall_status.as_deref(); + evidence.record(format!( + "no test result was reported for {} (summary {})", + summary.label, + status.unwrap_or("unknown") + )); + reported.push(TestOutcome { + target: summary.label.clone(), + configuration: summary.configuration.clone(), + outcome: summary_outcome(status), + status: summary.overall_status.clone(), + ..TestOutcome::default() + }); + } + for target in &events.unbuilt { + let summarized = events + .summaries + .iter() + .any(|summary| summary.label == target.label); + let has_result = events + .results + .iter() + .any(|result| result.label == target.label); + if summarized || has_result { + continue; + } + evidence.record(format!("{} failed to build", target.label)); + reported.push(TestOutcome { + target: target.label.clone(), + configuration: target.configuration.clone(), + outcome: "failed_to_build".to_owned(), + ..TestOutcome::default() + }); + } + for gap in &events.skipped { + let Some(label) = gap.label.clone() else { + continue; + }; + reported.push(TestOutcome { + target: label, + configuration: gap.configuration.clone(), + outcome: gap.outcome().to_owned(), + evidence_complete: true, + ..TestOutcome::default() + }); + } + for target in &events.filtered { + reported.push(TestOutcome { + target: target.label.clone(), + configuration: target.configuration.clone(), + outcome: "filtered".to_owned(), + evidence_complete: true, + ..TestOutcome::default() + }); + } + if !events.ended { + evidence.record("build events ended before Bazel's last event".to_owned()); + } +} + +pub(super) const FILTERED_LABELS_SHOWN: usize = 3; + +pub(super) fn filtered_notice( + output: OutputMode, + out: &mut dyn Write, + err: &mut dyn Write, + filtered: &[dx_bep::UnbuiltTest], +) -> Result<(), i32> { + if filtered.is_empty() { + return Ok(()); + } + let labels: Vec<&str> = filtered + .iter() + .take(FILTERED_LABELS_SHOWN) + .map(|target| target.label.as_str()) + .collect(); + let more = filtered.len().saturating_sub(labels.len()); + let mut message = format!( + "{} {} did not run: a test filter removed {}", + filtered.len(), + if filtered.len() == 1 { "test" } else { "tests" }, + labels.join(", ") + ); + if more > 0 { + message.push_str(&format!(" and {more} more")); + } + match output { + OutputMode::Json => { + emit_built( + out, + notice_event(&NoticeEvent::info(CODE_TESTS_FILTERED, message)), + )?; + } + OutputMode::Text { .. } => { + let _ = writeln!(err, "dx: tests_filtered: {message}"); + } + _ => {} + } + Ok(()) +} + +/// The results report: every outcome of the run, passed ones included. +pub(super) fn results_document( + reported: &[TestOutcome], + complete: bool, + bazel_code: i32, + run_id: &str, +) -> String { + let results: Vec = reported + .iter() + .filter_map(|outcome| test_result_record(outcome).ok()) + .collect(); + let document = serde_json::json!({ + "record_version": 1, + "run_id": run_id, + "exit_code": bazel_code, + "complete": complete, + "results": results, + }); + serde_json::to_string_pretty(&document).unwrap_or_default() +} + +/// `testSummary` overall statuses keyed by target and configuration. +pub(super) type Summaries<'a> = BTreeMap<(&'a str, Option<&'a str>), &'a str>; + +/// The outcome of one test result before any of its artifacts are read. +pub(super) fn outcome_for(result: &TestResultRecord, summaries: &Summaries) -> TestOutcome { + let summary = summaries + .get(&(result.label.as_str(), result.configuration.as_deref())) + .copied(); + TestOutcome { + target: result.label.clone(), + configuration: result.configuration.clone(), + outcome: result_outcome(result.status.as_deref(), summary), + status: result.status.clone(), + cached: result.cached_locally, + run: Some(result.run), + shard: Some(result.shard), + attempt: Some(result.attempt), + duration_millis: result.duration_millis, + ..TestOutcome::default() + } +} + +/// Reads one result artifact, or counts it missing and records why. +pub(super) fn read_or_record( + reader: &dyn ArtifactReader, + file: &TestResultFile, + where_: &str, + outcome: &mut TestOutcome, + evidence: &mut Evidence, +) -> Option> { + match reader.read_artifact(&file.exec_path) { + Ok(bytes) => Some(bytes), + Err(error) => { + outcome.artifacts_missing += 1; + evidence.record(format!( + "unreadable {where_} at {}: {error}", + stable_exec_path(&file.exec_path) + )); + None + } + } +} + +/// Parses one result's test.xml and stamps each case with the result's run and configuration. +pub(super) fn parse_cases( + result: &TestResultRecord, + bytes: &[u8], +) -> Result, ReportError> { + let shard = result.shard.saturating_sub(1); + let attempt = result.attempt.saturating_sub(1); + let mut cases = parse_test_xml(bytes, shard, attempt)?; + for case in &mut cases { + case.run = result.run.saturating_sub(1); + case.configuration = result.configuration.clone(); + } + Ok(cases) +} + +/// Why the results are incomplete: no artifacts at all, or any missing or invalid evidence. +pub(super) fn evidence_gap(empty: bool, evidence: &Evidence, missing_text: &str) -> Option { + if empty { + Some(if evidence.first.is_empty() { + missing_text.to_owned() + } else { + evidence.first.clone() + }) + } else if evidence.count > 0 { + Some(unusable_detail(evidence.count, &evidence.first)) + } else { + None + } +} diff --git a/cli/cli/src/exec/test_reports/collect.rs b/cli/cli/src/exec/test_reports/collect.rs new file mode 100644 index 0000000000..6b7d47725d --- /dev/null +++ b/cli/cli/src/exec/test_reports/collect.rs @@ -0,0 +1,337 @@ +use super::*; + +/// The aspect that writes coverage source manifests, as the build events name it. +pub(super) const SOURCES_ASPECT_SUFFIX: &str = "coverage_sources.bzl%dx_coverage_sources_aspect"; + +/// The coverage source manifests a run reported, and every problem that left one missing. +#[derive(Default)] +pub(super) struct Sources { + pub(super) manifests: Vec, + pub(super) missing: Vec, + pub(super) invalid: Vec, + /// `bazel ` from the build events' `started` event. + pub(super) collector: Option, + /// The checkout measured, when the workspace is a Git work tree. + pub(super) candidate: Option, + /// Why the tracked-source check did not run, when it is not an accounting error. + pub(super) tracked_unavailable: Option, +} + +/// Reads the coverage source manifests from the run's build events. +pub(super) fn collect_sources(bep: &Path, workspace: &Path) -> Sources { + let mut sources = Sources { + collector: dx_bep::stream_build_tool_version(bep) + .ok() + .flatten() + .map(|version| format!("bazel {version}")), + ..Sources::default() + }; + let account = match collect_output_account(bep, SOURCES_OUTPUT_GROUP, workspace) { + Ok(account) => account, + Err((_, message)) => { + sources.missing.push(format!( + "coverage source manifests are unreadable: {message}" + )); + return sources; + } + }; + for target in &account.targets { + if !target + .aspect + .as_deref() + .is_some_and(|aspect| aspect.ends_with(SOURCES_ASPECT_SUFFIX)) + { + continue; + } + if !target.success || target.incomplete { + sources.missing.push(format!( + "{} did not report its coverage source manifest", + target.label + )); + continue; + } + for artifact in &target.artifacts { + match parse_source_manifest(&String::from_utf8_lossy(&artifact.bytes)) { + Ok(manifest) => sources.manifests.push(manifest), + Err(error) => sources.invalid.push(format!( + "invalid coverage source manifest {}: {error}", + stable_exec_path(&artifact.exec_path) + )), + } + } + } + sources +} + +/// Everything one run's results produced: report inputs, outcomes and the evidence gap. +#[derive(Default)] +pub(super) struct Collected { + pub(super) suites: Vec<(String, Vec)>, + pub(super) lcov_documents: Vec, + pub(super) sources: Sources, + pub(super) reported: Vec, + pub(super) gap: Option, + /// Sorted, deduplicated (label, language) pairs that ran under coverage without a collector. + pub(super) no_collector: Vec<(String, String)>, +} + +/// Reads every result's test.xml into suites and outcomes for `dx test`. +pub(super) fn collect_test_xml( + events: &TestEvents, + summaries: &Summaries, + reader: &dyn ArtifactReader, + mut evidence: Evidence, + err: &mut dyn Write, +) -> Collected { + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut reported: Vec = Vec::new(); + for result in &events.results { + let mut outcome = outcome_for(result, summaries); + let mut cases_seen: Vec = Vec::new(); + let mut reports = 0usize; + for file in &result.files { + if file.name != "test.xml" { + continue; + } + reports += 1; + let where_ = result_where( + &result.label, + &file.name, + result.run, + result.shard, + result.attempt, + ); + let Some(bytes) = read_or_record(reader, file, &where_, &mut outcome, &mut evidence) + else { + continue; + }; + match parse_cases(result, &bytes) { + Ok(cases) => { + if std::str::from_utf8(&bytes).is_err() { + let _ = writeln!( + err, + "dx: warning: {where_} has invalid UTF-8; replaced invalid bytes" + ); + } + outcome.artifacts_collected += 1; + tally_cases(&mut outcome, &cases, &mut cases_seen); + grouped + .entry(result.label.clone()) + .or_default() + .extend(cases); + } + Err(error) => { + outcome.artifacts_invalid += 1; + evidence.record(format!( + "invalid {where_} at {}: {error}", + stable_exec_path(&file.exec_path) + )); + } + } + } + if reports == 0 { + outcome.artifacts_missing += 1; + evidence.record(format!( + "{} was not reported", + result_where( + &result.label, + "test.xml", + result.run, + result.shard, + result.attempt + ) + )); + } + outcome.evidence_complete = outcome.artifacts_collected > 0 + && outcome.artifacts_missing == 0 + && outcome.artifacts_invalid == 0; + if outcome.evidence_complete + && outcome.outcome == "passed" + && outcome.cases_total == Some(0) + { + outcome.outcome = "no_cases".to_owned(); + } + attach_log(&mut outcome, result, reader); + outcome.cases = cases_seen; + reported.push(outcome); + } + account_stream(events, &mut evidence, &mut reported); + let exempt = + events.results.is_empty() && (!events.skipped.is_empty() || !events.filtered.is_empty()); + let gap = evidence_gap( + grouped.is_empty() && !exempt, + &evidence, + "no test.xml artifacts were reported", + ); + Collected { + suites: grouped.into_iter().collect(), + reported, + gap, + ..Collected::default() + } +} + +/// Reads every result's final coverage output into LCOV documents and outcomes for `dx coverage`. +pub(super) fn collect_lcov( + events: &TestEvents, + summaries: &Summaries, + reader: &dyn ArtifactReader, + mut evidence: Evidence, + sources: Sources, +) -> Collected { + let finals = final_coverage_attempts(&events.results); + let mut lcov_documents: Vec = Vec::new(); + let mut reported: Vec = Vec::new(); + for result in &events.results { + let mut required = Vec::new(); + for file in &result.files { + if !is_coverage_output(&file.name) { + continue; + } + let key = ( + result.label.as_str(), + result.run, + result.shard, + file.name.as_str(), + ); + let Some(final_attempt) = finals.get(&key) else { + continue; + }; + if result.attempt < *final_attempt { + continue; + } + required.push(file); + } + if result.files.iter().any(is_coverage_file) && required.is_empty() { + continue; + } + let mut outcome = outcome_for(result, summaries); + for file in required { + let where_ = result_where( + &result.label, + &file.name, + result.run, + result.shard, + result.attempt, + ); + let Some(bytes) = read_or_record(reader, file, &where_, &mut outcome, &mut evidence) + else { + continue; + }; + match validate_lcov(&bytes) { + Ok(()) => { + outcome.artifacts_collected += 1; + lcov_documents.push(String::from_utf8_lossy(&bytes).into_owned()); + } + Err(error) => { + if bytes.iter().all(|b| b.is_ascii_whitespace()) { + outcome.artifacts_collected += 1; + continue; + } + outcome.artifacts_invalid += 1; + evidence.record(format!( + "invalid {where_} at {}: {error}", + stable_exec_path(&file.exec_path) + )); + } + } + } + outcome.evidence_complete = (outcome.artifacts_collected > 0 + && outcome.artifacts_missing == 0 + && outcome.artifacts_invalid == 0) + || !result.files.iter().any(is_coverage_file); + if outcome.outcome != "passed" { + // Best effort: coverage completeness stays about the LCOV files only. + let mut cases_seen: Vec = Vec::new(); + for file in result.files.iter().filter(|file| file.name == "test.xml") { + let Ok(bytes) = reader.read_artifact(&file.exec_path) else { + continue; + }; + if let Ok(cases) = parse_cases(result, &bytes) { + tally_cases(&mut outcome, &cases, &mut cases_seen); + } + } + outcome.cases = cases_seen; + attach_log(&mut outcome, result, reader); + } + reported.push(outcome); + } + account_stream(events, &mut evidence, &mut reported); + for problem in &sources.missing { + evidence.record(problem.clone()); + } + let no_collector = no_collector_labels(events); + // A run of only collector-less tests has no LCOV by design; the + // no_coverage_collector verdict names the cause instead of a missing-artifact gap. + let gap = evidence_gap( + lcov_documents.is_empty() && no_collector.is_empty(), + &evidence, + "no coverage.dat artifacts were reported", + ); + Collected { + lcov_documents, + sources, + reported, + gap, + no_collector, + ..Collected::default() + } +} + +/// Tests tagged as having no coverage collector that produced a result, as (label, language), once each. +pub(super) fn no_collector_labels(events: &TestEvents) -> Vec<(String, String)> { + let mut tests: Vec<(String, String)> = events + .results + .iter() + .filter_map(|result| { + let language = events.no_coverage_collector.get(&result.label)?; + Some((result.label.clone(), language.clone())) + }) + .collect(); + tests.sort(); + tests.dedup(); + tests +} + +/// Reports tests that ran under coverage with no collector; true when none should fail the run. +/// Without `strict` they are skipped with one summary line per language. +pub(super) fn announce_no_collector( + output: OutputMode, + out: &mut dyn Write, + err: &mut dyn Write, + tests: &[(String, String)], + strict: bool, +) -> Result { + if !strict { + let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for (_, language) in tests { + *counts.entry(language.as_str()).or_default() += 1; + } + for (language, count) in counts { + let noun = if count == 1 { "test" } else { "tests" }; + let message = + format!("skipped {count} {language} {noun}: no coverage collector (#7298)"); + if output == OutputMode::Json { + emit_built( + out, + notice_event(&NoticeEvent::info(CODE_NO_COVERAGE_COLLECTOR, message)), + )?; + } else { + let _ = writeln!(err, "dx: {message}"); + } + } + return Ok(true); + } + for (label, _) in tests { + let message = format!("no coverage collector for {label}"); + if output == OutputMode::Json { + emit_built( + out, + dx_output::error_event(CODE_NO_COVERAGE_COLLECTOR, &message, None, None, None), + )?; + } else { + let _ = writeln!(err, "dx: {CODE_NO_COVERAGE_COLLECTOR}: {message}"); + } + } + Ok(tests.is_empty()) +} diff --git a/cli/cli/src/exec/test_reports/report.rs b/cli/cli/src/exec/test_reports/report.rs new file mode 100644 index 0000000000..f5bb97c371 --- /dev/null +++ b/cli/cli/src/exec/test_reports/report.rs @@ -0,0 +1,458 @@ +use super::*; + +/// Prints each outcome in the run's output mode and counts the ones that were not superseded. +pub(super) fn emit_outcomes( + output: OutputMode, + out: &mut dyn Write, + err: &mut dyn Write, + reported: &[TestOutcome], + filtered: &[dx_bep::UnbuiltTest], +) -> Result { + let mut counts = TestCounts::default(); + if output == OutputMode::Json { + for outcome in reported { + let passed = outcome.evidence_complete + && matches!( + outcome.outcome.as_str(), + "passed" | "passed_after_retry" | "no_cases" + ); + if !outcome.superseded && passed { + counts.passed += 1; + if outcome.cached == Some(true) { + counts.cached += 1; + } + if outcome.outcome == "no_cases" { + counts.no_cases += 1; + } + } else if !outcome.superseded && is_failed(&outcome.outcome) { + counts.failed += 1; + } else if !outcome.superseded + && matches!(outcome.outcome.as_str(), "skipped" | "filtered") + { + counts.skipped += 1; + } + if outcome.evidence_complete + && (outcome.outcome == "passed" + || (outcome.outcome == "no_cases" && !outcome.superseded)) + { + continue; + } + emit_built(out, test_outcome_event(outcome))?; + } + filtered_notice(output, out, err, filtered)?; + } else if matches!(output, OutputMode::Text { .. }) { + filtered_notice(output, out, err, filtered)?; + for outcome in reported { + if outcome.outcome == "passed_after_retry" { + let attempt = outcome + .attempt + .map(|attempt| format!(" (attempt {attempt})")) + .unwrap_or_default(); + let _ = writeln!( + err, + "dx: flaky success (passed after retry): {}{attempt}", + outcome.target + ); + } + if !outcome.superseded && is_failed(&outcome.outcome) { + write_failed_line(err, outcome); + } + } + } + Ok(counts) +} + +/// Most failing case names one text-mode failure line lists. +pub(super) const TEXT_CASE_LIMIT: usize = 3; + +/// Prints `dx: failed: : (log: )` for one failed result. +pub(super) fn write_failed_line(err: &mut dyn Write, outcome: &TestOutcome) { + let failing: Vec<&str> = outcome + .cases + .iter() + .filter(|case| case.outcome == "failed" || case.outcome == "error") + .map(|case| case.name.as_str()) + .collect(); + let mut line = format!("dx: failed: {}", outcome.target); + if !failing.is_empty() { + line.push_str(": "); + line.push_str(&failing[..failing.len().min(TEXT_CASE_LIMIT)].join(", ")); + if failing.len() > TEXT_CASE_LIMIT { + line.push_str(&format!(" and {} more", failing.len() - TEXT_CASE_LIMIT)); + } + } + if let Some(log) = &outcome.log_path { + line.push_str(&format!(" (log: {log})")); + } + let _ = writeln!(err, "{line}"); +} + +/// Writes reports to their destinations and announces each one in the run's output mode. +pub(super) struct ReportSink<'a> { + pub(super) out: &'a mut dyn Write, + pub(super) err: &'a mut dyn Write, + pub(super) output: OutputMode, + pub(super) workspace: &'a Path, + pub(super) stdout_report: bool, +} + +impl ReportSink<'_> { + /// Writes one named report and announces it; false when the write failed and was reported. + pub(super) fn write( + &mut self, + name: &str, + destination: &Destination, + document: &str, + complete: bool, + ) -> Result { + if let Err(error) = write_report_document(self.out, self.workspace, destination, document) { + self.failed(&format!("failed to write {name} report to {error}"), None)?; + return Ok(false); + } + if self.output == OutputMode::Json { + emit_built( + self.out, + report_event(name, destination.display(), complete), + )?; + } else if matches!(self.output, OutputMode::Text { .. }) && !self.stdout_report { + let _ = writeln!( + self.out, + "Wrote {name} report to {}.", + destination.display() + ); + } + Ok(true) + } + + /// Reports one report that could not be produced, with the next step for its cause. + pub(super) fn failed(&mut self, detail: &str, next: Option<&str>) -> Result<(), i32> { + report_failed_with_next(self.out, self.err, self.output, detail, next) + } +} + +/// The verb's report document, or why it could not be rendered. +pub(super) fn render_report( + verb: WorkflowVerb, + suites: &[(String, Vec)], + lcov_documents: &[String], + detail: &str, + format: StandardFormat, + sources_document: Option<&str>, +) -> Result { + if format == StandardFormat::Sources { + return match sources_document { + Some(document) if detail.is_empty() => Ok(document.to_owned()), + _ => Err(if detail.is_empty() { + "no coverage source account is available".to_owned() + } else { + detail.to_owned() + }), + }; + } + match verb { + WorkflowVerb::Test => render_junit(suites).map_err(|error| error.to_string()), + WorkflowVerb::Coverage => { + if lcov_documents.is_empty() { + Err(detail.to_owned()) + } else { + let mut combined = lcov_documents.join("\n"); + if !combined.ends_with('\n') { + combined.push('\n'); + } + Ok(combined) + } + } + _ => Err(detail.to_owned()), // LCOV_EXCL_LINE - reason: defensive arm, issue: 1055, policy: docs/cli/commands/build-test-coverage.md + } +} + +/// Reports why the results are incomplete, when evidence was missing or invalid. +pub(super) fn announce_incomplete( + output: OutputMode, + out: &mut dyn Write, + err: &mut dyn Write, + detail: &str, +) -> Result<(), i32> { + if detail.is_empty() { + return Ok(()); + } + if output == OutputMode::Json { + emit_built( + out, + dx_output::error_event("incomplete_results", detail, None, None, None), + )?; + } else { + let _ = writeln!(err, "dx: incomplete_results: {detail}"); + } + Ok(()) +} + +/// Checks the cell's owned-source line coverage against `--min-coverage`; true when there is no minimum. +pub(super) fn coverage_threshold( + minimum: Option, + account: Option<&CellAccount>, + sources: &Sources, + output: OutputMode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let Some(minimum) = minimum else { + return Ok(true); + }; + let Some(account) = account else { + return Ok(false); + }; + let covered = account.covered_lines; + let eligible = account.eligible_lines; + let errors = sources.invalid.len() + account.errors.len(); + let threshold_ok = errors == 0 && account.meets(minimum); + let lines = if eligible > 0 { + let percent = 100.0 * covered as f64 / eligible as f64; + format!("coverage {percent:.2}% ({covered}/{eligible} lines)") + } else { + format!("coverage ({covered}/{eligible} lines)") + }; + let cell = &account.cell; + let summary = if errors > 0 { + let noun = if errors == 1 { "gap" } else { "gaps" }; + format!( + "{lines} in package {cell} has {errors} coverage source {noun} (see {CODE_COVERAGE_SOURCES_INCOMPLETE}); minimum {minimum}% (coverage_below_minimum)" + ) + } else if eligible == 0 { + format!( + "no executable lines in the owned sources of package {cell} (coverage_below_minimum)" + ) + } else if threshold_ok { + format!("{lines} meets minimum {minimum}% in package {cell}") + } else { + format!("{lines} below minimum {minimum}% in package {cell} (coverage_below_minimum)") + }; + if output == OutputMode::Json { + if !threshold_ok { + emit_built( + out, + dx_output::error_event(CODE_COVERAGE_BELOW_MINIMUM, &summary, None, None, None), + )?; + } + } else { + let _ = writeln!(err, "dx: {summary}"); + } + Ok(threshold_ok) +} + +/// How many files the text coverage table lists. +pub(super) const WORST_FILES: usize = 10; + +/// Prints the total coverage line (when no minimum prints it) and the least covered files. +pub(super) fn coverage_summary( + account: &CellAccount, + show_total: bool, + output: OutputMode, + err: &mut dyn Write, +) { + let OutputMode::Text { quiet: false } = output else { + return; + }; + let covered = account.covered_lines; + let eligible = account.eligible_lines; + if show_total { + if eligible > 0 { + let percent = 100.0 * covered as f64 / eligible as f64; + let _ = writeln!( + err, + "dx: coverage {percent:.1}% ({covered}/{eligible} lines)" + ); + } else { + let _ = writeln!(err, "dx: coverage (0/0 lines)"); + } + } + let mut worst: Vec<(&String, u64, u64)> = account + .file_lines + .iter() + .filter(|(_, (covered, eligible))| covered < eligible) + .map(|(path, (covered, eligible))| (path, *covered, *eligible)) + .collect(); + worst.sort_by(|a, b| { + (a.1 * b.2) + .cmp(&(b.1 * a.2)) + .then((b.2 - b.1).cmp(&(a.2 - a.1))) + .then(a.0.cmp(b.0)) + }); + if worst.is_empty() { + return; + } + let _ = writeln!(err, "dx: least covered files:"); + for (path, covered, eligible) in worst.into_iter().take(WORST_FILES) { + let percent = 100.0 * covered as f64 / eligible as f64; + let _ = writeln!(err, " {percent:5.1}% {covered}/{eligible} {path}"); + } +} + +/// Accounts every owned source of the run against the merged coverage. +pub(super) fn account_sources( + sources: &Sources, + lcov_documents: &[String], + workspace: &Path, +) -> dx_lcov::CellAccount { + dx_lcov::account(&sources.manifests, lcov_documents, &|path| { + std::fs::read(workspace.join(path)) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .map_err(|error| dx_lcov::LcovError::Io { + message: format!("{path}: {error}"), + }) + }) +} + +/// The `sources` report: the cell's owned sources with dispositions and the production sources the report never saw. +pub(super) fn sources_report(sources: &Sources, account: &dx_lcov::CellAccount) -> String { + let owned: std::collections::BTreeMap<&str, &str> = account + .files + .iter() + .map(|(path, disposition)| { + let name = match disposition { + dx_lcov::Disposition::Eligible => "eligible", + dx_lcov::Disposition::Support => "support", + dx_lcov::Disposition::Excluded => "excluded", + dx_lcov::Disposition::Unsupported => "unsupported", + }; + (path.as_str(), name) + }) + .chain(account.absent.iter().map(|path| (path.as_str(), "absent"))) + .collect(); + let errors: Vec<&String> = sources.invalid.iter().chain(&account.errors).collect(); + let candidate = sources + .candidate + .as_ref() + .map(|found| serde_json::json!({"sha": found.sha, "dirty": found.dirty})); + let document = serde_json::json!({ + "record_version": 2, + "cell": account.cell, + "candidate": candidate, + "collector": sources.collector, + "owned": owned, + "absent": account.absent, + "orphans": account.orphans, + "errors": errors, + }); + serde_json::to_string_pretty(&document).unwrap_or_default() + "\n" +} + +/// Prints the account's counts and every gap; true when none. +/// With `enforce` (`--min-coverage`) each gap is a JSON `error` event instead of a warning notice. +pub(super) fn coverage_sources( + sources: &Sources, + account: &dx_lcov::CellAccount, + output: OutputMode, + enforce: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let summary = account.summary(); + let errors: Vec<&String> = sources.invalid.iter().chain(&account.errors).collect(); + let info: fn(&str, String) -> NoticeEvent = NoticeEvent::info; + let warning: fn(&str, String) -> NoticeEvent = NoticeEvent::warning; + let unavailable = sources + .tracked_unavailable + .iter() + .map(|why| (warning, CODE_COVERAGE_SOURCES, why)); + let notices = std::iter::once((info, CODE_COVERAGE_SOURCES, &summary)) + .chain(unavailable) + .chain( + errors + .iter() + .map(|message| (warning, CODE_COVERAGE_SOURCES_INCOMPLETE, *message)), + ); + for (notice, code, message) in notices { + match output { + OutputMode::Json if enforce && code == CODE_COVERAGE_SOURCES_INCOMPLETE => { + emit_built(out, dx_output::error_event(code, message, None, None, None))?; + } + OutputMode::Json => { + emit_built(out, notice_event(¬ice(code, message.clone())))?; + } + OutputMode::Text { .. } if code == CODE_COVERAGE_SOURCES => { + let _ = writeln!(err, "dx: {message}"); + } + OutputMode::Text { .. } => { + let _ = writeln!(err, "dx: {code}: {message}"); + } + _ => {} + } + } + Ok(errors.is_empty()) +} + +/// Emits the cell's `coverage` event in JSON mode. +pub(super) fn coverage_event( + sources: &Sources, + account: &CellAccount, + minimum: Option, + output: OutputMode, + out: &mut dyn Write, +) -> Result<(), i32> { + if output != OutputMode::Json { + return Ok(()); + } + let errors = (sources.invalid.len() + account.errors.len()) as u64; + let event = dx_output::CoverageEvent { + cell: account.cell.clone(), + covered_lines: account.covered_lines, + eligible_lines: account.eligible_lines, + raw_lines: account.raw_lines, + excluded_lines: account.excluded_lines, + eligible_files: account.eligible_files, + support_files: account.support_files, + excluded_files: account.excluded_files, + unsupported_files: account.unsupported_files, + minimum, + passed: errors == 0 && account.meets(minimum.unwrap_or(0)), + errors, + dx_version: dx_adopt::DX_VERSION.to_owned(), + sha: sources.candidate.as_ref().map(|found| found.sha.clone()), + dirty: sources.candidate.as_ref().is_some_and(|found| found.dirty), + collector: sources.collector.clone(), + }; + emit_built(out, dx_output::coverage_event(&event)) +} + +/// Reads the run's test events; a stream Bazel cut short is a problem only when Bazel failed too. +pub(super) fn read_events( + invocation: &Invocation, + workspace: &Path, + verb: WorkflowVerb, + bep: &Path, + bazel_code: i32, + query_runner: &dyn QueryRunner, +) -> Result<(TestEvents, Option), (&'static str, String)> { + let file = match std::fs::File::open(bep) { + Ok(file) => file, + Err(error) if bazel_code != 0 => { + return Ok(( + TestEvents::default(), + Some(format!("build events are unavailable: {error}")), + )); + } + Err(error) => { + return Err(( + CODE_UNREADABLE_BEP, + format!("failed to read build events: {error}"), + )); + } + }; + match collect_test_events(BufReader::new(file), || { + Some(output_locations(invocation, workspace, verb, query_runner)) + }) { + Ok(events) => match events.problem.clone() { + Some(problem) if bazel_code == 0 => { + Err((CODE_INVALID_BEP, format!("invalid build events: {problem}"))) + } + Some(problem) => Ok((events, Some(format!("build events end early: {problem}")))), + None => Ok((events, None)), + }, + Err(error) if bazel_code != 0 => Ok(( + TestEvents::default(), + Some(format!("invalid build events: {error}")), + )), + Err(error) => Err((CODE_INVALID_BEP, format!("invalid build events: {error}"))), + } +} From 90b438d90c85a85d99bfe6fc9116ae27c6c63aad Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Mon, 9 Nov 2026 17:25:45 +0100 Subject: [PATCH 4/5] dx: move slots, release lib, oci, backend and dependency_sets tests to sibling files (part of #10053) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hwESun1HL2eSVd1d5B --- cli/adopt/BUILD.bazel | 1 + cli/adopt/src/dependency_sets.rs | 1997 +-------------- cli/adopt/src/dependency_sets_tests.rs | 1991 +++++++++++++++ cli/plan/BUILD.bazel | 1 + cli/plan/src/slots.rs | 2931 +---------------------- cli/plan/src/slots_tests.rs | 2923 ++++++++++++++++++++++ cli/update/BUILD.bazel | 1 + cli/update/src/backend.rs | 2470 +------------------ cli/update/src/backend_tests.rs | 2456 +++++++++++++++++++ deploy/release/BUILD.bazel | 1 + deploy/release/src/lib.rs | 1676 +------------ deploy/release/src/release_lib_tests.rs | 1650 +++++++++++++ deploy/rules/BUILD.bazel | 1 + deploy/rules/src/oci.rs | 1988 +-------------- deploy/rules/src/oci_tests.rs | 1979 +++++++++++++++ 15 files changed, 11014 insertions(+), 11052 deletions(-) create mode 100644 cli/adopt/src/dependency_sets_tests.rs create mode 100644 cli/plan/src/slots_tests.rs create mode 100644 cli/update/src/backend_tests.rs create mode 100644 deploy/release/src/release_lib_tests.rs create mode 100644 deploy/rules/src/oci_tests.rs diff --git a/cli/adopt/BUILD.bazel b/cli/adopt/BUILD.bazel index f7333165bd..fc42fe6065 100644 --- a/cli/adopt/BUILD.bazel +++ b/cli/adopt/BUILD.bazel @@ -131,6 +131,7 @@ rust_test( "src/build_output.rs", "src/defaults.rs", "src/dependency_sets.rs", + "src/dependency_sets_tests.rs", "src/error.rs", "src/families.rs", "src/git_changes.rs", diff --git a/cli/adopt/src/dependency_sets.rs b/cli/adopt/src/dependency_sets.rs index 143503ee7e..6097dff0a1 100644 --- a/cli/adopt/src/dependency_sets.rs +++ b/cli/adopt/src/dependency_sets.rs @@ -2465,1998 +2465,5 @@ fn check_uv_settings(workspace: &Path, set: &ResolvedSet) -> Result<(), Dependen } #[cfg(test)] -mod tests { - use super::*; - - type Spec<'a> = (&'a str, Ecosystem, &'a [&'a str], &'a [&'a str]); - - fn strings(words: &[&str]) -> Vec { - words.iter().map(|word| (*word).to_owned()).collect() - } - - fn try_registry(specs: &[Spec<'_>]) -> Result { - let mut sets = Vec::new(); - for (name, ecosystem, manifests, locks) in specs { - sets.push(hub_set( - name, - *ecosystem, - strings(manifests), - strings(locks), - )?); - } - check_conflicts(&sets)?; - sets.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Registry { sets }) - } - - fn registry(specs: &[Spec<'_>]) -> Registry { - try_registry(specs).expect("valid registry") - } - - fn two_uv() -> Registry { - registry(&[ - ( - "frontend", - Ecosystem::Uv, - &["apps/frontend/pyproject.toml"], - &["apps/frontend/uv.lock"], - ), - ( - "worker", - Ecosystem::Uv, - &["services/worker/pyproject.toml"], - &["services/worker/uv.lock"], - ), - ]) - } - - fn discovered( - root: &str, - files: &[(&str, &str)], - ) -> Result, DependencySetsError> { - let files: BTreeMap = files - .iter() - .map(|(path, text)| ((*path).to_owned(), (*text).to_owned())) - .collect(); - discover(root, &|rel: &str| { - files - .get(rel) - .cloned() - .ok_or_else(|| format!("{rel}: missing")) - }) - } - - fn found(root: &str, files: &[(&str, &str)]) -> Registry { - discovered(root, files) - .expect("valid hubs") - .expect("hubs present") - } - - const CRATE_EXT: &str = - "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\n"; - const MAVEN_EXT: &str = - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\n"; - const NPM_EXT: &str = - "npm = use_extension(\"@aspect_rules_js//npm:extensions.bzl\", \"npm\")\n"; - const UV_EXT: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\n"; - - #[test] - fn cargo_hubs_become_sets_with_the_lock_dir_manifest_first() { - let module = format!( - "{CRATE_EXT}crate.from_cargo(name = \"dx_crates\", cargo_lockfile = \"//b:Cargo.lock\", lockfile = \"//:cargo-bazel-lock.json\", manifests = [\"//a:Cargo.toml\", \"//b:Cargo.toml\"])\ncrate.from_cargo(name = \"nolock\", manifests = [\"//c:Cargo.toml\"])\n" - ); - let registry = found(&module, &[]); - assert_eq!(registry.names(), vec!["dx_crates"]); - let set = registry.find("dx_crates").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Cargo); - assert_eq!(set.manifests, ["b/Cargo.toml", "a/Cargo.toml"]); - assert_eq!(set.locks, ["b/Cargo.lock", "cargo-bazel-lock.json"]); - assert_eq!(set.scopes, [".", "a", "b"]); - assert_eq!(set.repository.as_deref(), Some("dx_crates")); - assert_eq!(ResolvedSet::of(set).dir, "b"); - } - - #[test] - fn maven_installs_with_a_lock_file_become_sets() { - let module = format!( - "{MAVEN_EXT}maven.install(lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"nolock\")\n" - ); - let registry = found(&module, &[]); - assert_eq!(registry.names(), vec!["maven"]); - let set = registry.find("maven").expect("maven"); - assert_eq!(set.ecosystem, Ecosystem::Maven); - assert_eq!(set.manifests, [MODULE_BAZEL]); - assert_eq!(set.locks, ["third_party/maven_install.json"]); - assert_eq!(set.scopes, ["third_party"]); - assert_eq!(set.repository.as_deref(), Some("maven")); - } - - #[test] - fn npm_translate_locks_become_sets() { - let module = format!( - "{NPM_EXT}npm.npm_translate_lock(name = \"web\", pnpm_lock = \"//apps/web:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"bare\")\n" - ); - let registry = found(&module, &[]); - assert_eq!(registry.names(), vec!["web"]); - let set = registry.find("web").expect("web"); - assert_eq!(set.ecosystem, Ecosystem::Npm); - assert_eq!(set.manifests, ["apps/web/package.json"]); - assert_eq!(set.locks, ["apps/web/pnpm-lock.yaml"]); - assert_eq!(set.repository, None); - } - - #[test] - fn uv_projects_are_named_by_hub_and_directory() { - let module = format!( - "{UV_EXT}uv.project(hub_name = \"pypi\", lock = \"//apps/api:uv.lock\", pyproject = \"//apps/api:pyproject.toml\")\nuv.project(hub_name = \"pypi\", lock = \"//:uv.lock\", pyproject = \"//:pyproject.toml\")\n" - ); - let registry = found(&module, &[]); - assert_eq!(registry.names(), vec!["pypi", "pypi-api"]); - let api = registry.find("pypi-api").expect("api"); - assert_eq!(api.ecosystem, Ecosystem::Uv); - assert_eq!(api.manifests, ["apps/api/pyproject.toml"]); - assert_eq!(api.locks, ["apps/api/uv.lock"]); - assert_eq!(registry.find("pypi").expect("root").scopes, ["."]); - } - - #[test] - fn go_deps_files_become_the_go_set() { - let module = "go_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//third_party/go:go.mod\")\n"; - let registry = found(module, &[]); - let set = registry.find("go").expect("go"); - assert_eq!(set.ecosystem, Ecosystem::Go); - assert_eq!(set.manifests, ["third_party/go/go.mod"]); - assert_eq!(set.locks, ["third_party/go/go.sum"]); - } - - #[test] - fn opam_packages_become_opam_sets() { - let module = "ocaml = use_extension(\"//ocaml/toolchain:extensions.bzl\", \"ocaml\")\nocaml.lock(name = \"app_opam\", lock = \"//ocaml/app:app.opam.locked\")\n"; - let registry = found(module, &[]); - let set = registry.find("app_opam").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Opam); - assert_eq!(set.manifests, ["ocaml/app/app.opam"]); - assert_eq!(set.locks, ["ocaml/app/app.opam.locked"]); - assert!(check_opam_layout(&ResolvedSet::of(set)).is_ok()); - } - - #[test] - fn haskell_packages_become_cabal_sets() { - let module = "haskell = use_extension(\"//haskell/toolchain:extensions.bzl\", \"haskell\")\nhaskell.lock(name = \"hackage\", lock = \"//third_party:haskell_deps.json\")\n"; - let registry = found(module, &[]); - let set = registry.find("hackage").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Cabal); - assert_eq!(set.manifests, ["third_party/cabal.project"]); - assert_eq!( - set.locks, - [ - "third_party/cabal.project.freeze", - "third_party/haskell_deps.json" - ] - ); - } - - #[test] - fn cabal_layout_compares_freeze_and_sidecar_versions() { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-cabal-"); - let root = scratch.path(); - std::fs::create_dir_all(root.join("hs")).expect("dir"); - std::fs::write( - root.join("hs/cabal.project.freeze"), - "constraints: any.base ==4.20.2.0,\n any.safe ==0.3.21\nindex-state: hackage.haskell.org 2026-09-30T23:43:34Z\n", - ) - .expect("freeze"); - let sidecar = |safe: &str| { - format!( - "{{\"packages\":[{{\"name\":\"base\",\"version\":\"4.20.2.0\"}},{{\"name\":\"safe\",\"version\":\"{safe}\"}}]}}" - ) - }; - let set = ResolvedSet { - name: "hackage".to_owned(), - ecosystem: Ecosystem::Cabal, - dir: "hs".to_owned(), - manifests: vec!["hs/cabal.project".to_owned()], - locks: vec![ - "hs/cabal.project.freeze".to_owned(), - "hs/haskell_deps.json".to_owned(), - ], - repository: None, - }; - std::fs::write(root.join("hs/haskell_deps.json"), sidecar("0.3.21")).expect("sidecar"); - assert!(check_cabal_layout(root, &set).is_ok()); - std::fs::write(root.join("hs/haskell_deps.json"), sidecar("0.3.20")).expect("sidecar"); - let message = check_cabal_layout(root, &set) - .expect_err("stale") - .to_string(); - assert!(message.contains("safe 0.3.20"), "{message}"); - } - - #[test] - fn cpan_installs_become_cpan_sets() { - let module = "cpan = use_extension(\"@rules_perl//perl/cpan:extensions.bzl\", \"cpan\")\ncpan.install(name = \"perl_cpan\", lock = \"//perl/tools:cpanfile.snapshot.lock.json\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("perl_cpan").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Cpan); - assert_eq!(set.manifests, ["perl/tools/cpanfile"]); - assert_eq!( - set.locks, - [ - "perl/tools/cpanfile.snapshot", - "perl/tools/cpanfile.snapshot.lock.json" - ] - ); - } - - const CPAN_SNAPSHOT: &str = "# carton snapshot format: version 1.0\nDISTRIBUTIONS\n Try-Tiny-0.31\n pathname: E/ET/ETHER/Try-Tiny-0.31.tar.gz\n"; - - fn cpan_workspace(lock: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-cpan-"); - let root = scratch.path(); - std::fs::create_dir_all(root.join("perl")).expect("dir"); - std::fs::write(root.join("perl/cpanfile.snapshot"), CPAN_SNAPSHOT).expect("snapshot"); - std::fs::write(root.join("perl/cpanfile.snapshot.lock.json"), lock).expect("lock"); - std::fs::write( - root.join("perl/BUILD.bazel"), - "load(\"@rules_perl//perl/cpan:cpan_compiler.bzl\", \"cpan_compiler\")\n\ncpan_compiler(\n name = \"compiler\",\n cpanfile = \"cpanfile\",\n lockfile = \"cpanfile.snapshot.lock.json\",\n)\n", - ) - .expect("build"); - let set = ResolvedSet { - name: "perl_cpan".to_owned(), - ecosystem: Ecosystem::Cpan, - dir: "perl".to_owned(), - manifests: vec!["perl/cpanfile".to_owned()], - locks: vec![ - "perl/cpanfile.snapshot".to_owned(), - "perl/cpanfile.snapshot.lock.json".to_owned(), - ], - repository: None, - }; - (scratch, set) - } - - #[test] - fn cpan_compiler_label_reads_the_build_file_beside_the_lock() { - let (scratch, set) = cpan_workspace("{}"); - assert_eq!( - cpan_compiler_label(scratch.path(), &set).as_deref(), - Some("//perl:compiler") - ); - std::fs::write(scratch.path().join("perl/BUILD.bazel"), "").expect("build"); - assert_eq!(cpan_compiler_label(scratch.path(), &set), None); - } - - #[test] - fn cpan_layout_accepts_a_lock_that_matches_the_snapshot() { - let (scratch, set) = cpan_workspace("{\"Try-Tiny\":{\"strip_prefix\":\"Try-Tiny-0.31\"}}"); - assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); - } - - #[test] - fn cpan_layout_rejects_a_stale_lock_json() { - let (scratch, set) = cpan_workspace("{\"Try-Tiny\":{\"strip_prefix\":\"Try-Tiny-0.30\"}}"); - let error = check_backend_settings(scratch.path(), &set).expect_err("stale"); - let message = error.to_string(); - assert!(message.contains("Try-Tiny-0.31"), "{message}"); - assert!(message.contains("run the cpan_compiler"), "{message}"); - } - - fn composer_workspace(digest_reference: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-composer-"); - let root = scratch.path(); - std::fs::create_dir_all(root.join("php")).expect("dir"); - std::fs::write( - root.join("php/composer.lock"), - "{\"packages\":[{\"name\":\"psr/log\",\"version\":\"3.0.0\",\"dist\":{\"reference\":\"aaa\"}}],\"packages-dev\":[]}", - ) - .expect("lock"); - std::fs::write( - root.join("php/composer.digests.json"), - format!("{{\"packages\":{{\"psr/log\":{{\"reference\":\"{digest_reference}\"}}}}}}"), - ) - .expect("digests"); - let set = ResolvedSet { - name: "php".to_owned(), - ecosystem: Ecosystem::Composer, - dir: "php".to_owned(), - manifests: vec!["php/composer.json".to_owned()], - locks: vec![ - "php/composer.lock".to_owned(), - "php/composer.digests.json".to_owned(), - ], - repository: None, - }; - (scratch, set) - } - - #[test] - fn composer_locks_become_composer_sets() { - let module = "composer = use_extension(\"@rules_dx//php/composer:extensions.bzl\", \"composer\")\ncomposer.lock(name = \"vendor\", lock = \"//php:composer.lock\", digests = \"//php:composer.digests.json\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("vendor").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Composer); - assert_eq!(set.manifests, ["php/composer.json"]); - assert_eq!( - set.locks, - ["php/composer.lock", "php/composer.digests.json"] - ); - } - - fn dub_workspace(digest_version: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-dub-"); - let root = scratch.path(); - std::fs::create_dir_all(root.join("d")).expect("dir"); - std::fs::write( - root.join("d/dub.selections.json"), - "{\"fileVersion\":1,\"versions\":{\"semver\":\"0.8.0\"}}", - ) - .expect("selections"); - std::fs::write( - root.join("d/dub.digests.json"), - format!("{{\"packages\":{{\"semver\":{{\"version\":\"{digest_version}\"}}}}}}"), - ) - .expect("digests"); - let set = ResolvedSet { - name: "dub".to_owned(), - ecosystem: Ecosystem::Dub, - dir: "d".to_owned(), - manifests: vec!["d/dub.json".to_owned()], - locks: vec![ - "d/dub.selections.json".to_owned(), - "d/dub.digests.json".to_owned(), - ], - repository: None, - }; - (scratch, set) - } - - #[test] - fn dub_locks_become_dub_sets() { - let module = "dub = use_extension(\"@rules_dx//d/dub:extensions.bzl\", \"dub\")\ndub.lock(name = \"dub_deps\", lock = \"//d:dub.selections.json\", digests = \"//d:dub.digests.json\")\n"; - let read = |path: &str| match path { - "d/dub.sdl" => Ok(String::new()), - _ => Err("none".to_owned()), - }; - let registry = discover(module, &read) - .expect("discovers") - .expect("registry"); - let set = registry.find("dub_deps").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Dub); - assert_eq!(set.manifests, ["d/dub.sdl"]); - assert_eq!(set.locks, ["d/dub.selections.json", "d/dub.digests.json"]); - } - - #[test] - fn luarocks_locks_become_luarocks_sets() { - let module = "luarocks = use_extension(\"@rules_dx//lua/luarocks:extensions.bzl\", \"luarocks\")\nluarocks.lock(name = \"rocks\", lock = \"//lua:luarocks.lock\", digests = \"//lua:luarocks.digests.json\")\n"; - let registry = found(module, &[]); - let set = registry.find("rocks").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::LuaRocks); - assert_eq!(set.manifests, ["lua/luarocks.lock"]); - assert_eq!(set.locks, ["lua/luarocks.lock", "lua/luarocks.digests.json"]); - assert!(check_backend_settings(Path::new("."), &ResolvedSet::of(set)).is_ok()); - assert_eq!(Ecosystem::parse("luarocks"), Some(Ecosystem::LuaRocks)); - assert_eq!(Ecosystem::LuaRocks.advisory_family(), None); - assert_eq!(Ecosystem::LuaRocks.supported_update(), "pinned"); - assert!(validate_package(Ecosystem::LuaRocks, "lua-cjson").is_ok()); - } - - #[test] - fn ansible_and_bicep_tags_become_sets() { - let module = "ansible = use_extension(\"//ansible:extensions.bzl\", \"ansible\")\nansible.lock(name = \"cols\", lock = \"//play:lock.json\")\nbicep = use_extension(\"//bicep:extensions.bzl\", \"bicep\")\nbicep.registry_module(name = \"storage\", ref = \"br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.0\", digest = \"sha256:abc\")\nbicep.registry_module(name = \"network\", ref = \"br:mcr.microsoft.com/bicep/avm/res/network/virtual-network:0.1.0\", digest = \"sha256:def\")\n"; - let registry = found(module, &[]); - let cols = registry.find("cols").expect("set"); - assert_eq!(cols.ecosystem, Ecosystem::Ansible); - assert_eq!(cols.manifests, ["MODULE.bazel"]); - assert_eq!(cols.locks, ["play/lock.json"]); - assert!(check_ansible_layout(&ResolvedSet::of(cols)).is_ok()); - let storage = registry.find("storage").expect("set"); - assert_eq!(storage.ecosystem, Ecosystem::Bicep); - assert!(storage.locks.is_empty()); - assert_eq!(Ecosystem::parse("bicep"), Some(Ecosystem::Bicep)); - assert_eq!(Ecosystem::Ansible.supported_update(), "update"); - } - - #[test] - fn dub_layout_accepts_matching_digests() { - let (scratch, set) = dub_workspace("0.8.0"); - assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); - } - - #[test] - fn dub_layout_rejects_a_stale_digest() { - let (scratch, set) = dub_workspace("0.7.0"); - let message = check_backend_settings(scratch.path(), &set) - .expect_err("stale") - .to_string(); - assert!(message.contains("semver"), "{message}"); - assert!(message.contains("0.7.0"), "{message}"); - } - - #[test] - fn composer_layout_accepts_matching_digests() { - let (scratch, set) = composer_workspace("aaa"); - assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); - } - - #[test] - fn composer_layout_rejects_a_stale_digest() { - let (scratch, set) = composer_workspace("bbb"); - let error = check_backend_settings(scratch.path(), &set).expect_err("stale"); - let message = error.to_string(); - assert!(message.contains("psr/log"), "{message}"); - assert!(message.contains("aaa"), "{message}"); - } - - #[test] - fn swift_from_package_tags_become_swiftpm_sets() { - let module = "swift_deps = use_extension(\"@rules_swift_package_manager//:extensions.bzl\", \"swift_deps\")\nswift_deps.from_package(resolved = \"//app:Package.resolved\", swift = \"//app:Package.swift\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("swiftpm").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::SwiftPm); - assert_eq!(set.manifests, ["app/Package.swift"]); - assert_eq!(set.locks, ["app/Package.resolved"]); - } - - #[test] - fn julia_from_manifest_tags_become_julia_sets() { - let module = "pkg = use_extension(\"@rules_julia//julia/pkg:extensions.bzl\", \"pkg\")\npkg.from_manifest(name = \"jl_deps\", lockfile = \"//third_party/julia:Manifest.bazel.json\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("jl_deps").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Julia); - assert_eq!(set.manifests, ["third_party/julia/Project.toml"]); - assert_eq!( - set.locks, - [ - "third_party/julia/Manifest.toml", - "third_party/julia/Manifest.bazel.json" - ] - ); - } - - #[test] - fn julia_layout_needs_both_locks_to_name_the_same_versions() { - let dir = std::env::temp_dir().join(format!("dx-julia-layout-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("dir"); - let manifest = "manifest_format = \"2.0\"\n[[deps.Glob]]\nversion = \"1.3.1\"\n[[deps.Dates]]\nuuid = \"x\"\n"; - std::fs::write(dir.join("Manifest.toml"), manifest).expect("write"); - std::fs::write( - dir.join("Manifest.bazel.json"), - "{\"Glob\": {\"version\": \"1.3.1\"}}", - ) - .expect("write"); - let set = ResolvedSet { - name: "jl".to_owned(), - ecosystem: Ecosystem::Julia, - dir: ".".to_owned(), - manifests: vec!["Project.toml".to_owned()], - locks: vec!["Manifest.toml".to_owned(), "Manifest.bazel.json".to_owned()], - repository: None, - }; - assert!(check_julia_layout(&dir, &set).is_ok()); - std::fs::write( - dir.join("Manifest.bazel.json"), - "{\"Glob\": {\"version\": \"1.3.0\"}}", - ) - .expect("write"); - let message = check_julia_layout(&dir, &set).unwrap_err().to_string(); - assert!(message.contains("Glob 1.3.0"), "{message}"); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn pub_locks_become_pub_sets() { - let module = "pub = use_extension(\"@rules_dart//dart/pub:extensions.bzl\", \"pub\")\npub.from_lock(name = \"dart_deps\", lock = \"//third_party/dart:pubspec.lock\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("dart_deps").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Pub); - assert_eq!(set.manifests, ["third_party/dart/pubspec.yaml"]); - assert_eq!(set.locks, ["third_party/dart/pubspec.lock"]); - } - - #[test] - fn elixir_mix_tags_become_hex_sets() { - let module = "elixir = use_extension(\"//elixir/toolchain:extensions.bzl\", \"elixir\")\nelixir.lock(name = \"deps\", lock = \"//third_party/elixir:mix.lock\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("deps").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Hex); - assert_eq!(set.manifests, ["third_party/elixir/mix.exs"]); - assert_eq!(set.locks, ["third_party/elixir/mix.lock"]); - } - - #[test] - fn erlang_rebar_tags_become_rebar_hex_sets() { - let module = "erlang = use_extension(\"//erlang/toolchain:repos.bzl\", \"erlang\")\nerlang.rebar(name = \"deps\", lock = \"//third_party/erlang:rebar.lock\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("deps").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Hex); - assert_eq!(set.manifests, ["third_party/erlang/rebar.config"]); - assert_eq!(set.locks, ["third_party/erlang/rebar.lock"]); - let resolved = ResolvedSet::of(set); - assert_eq!(hex_backend(&resolved), Some(HexBackend::Rebar3)); - let mix = ResolvedSet { - locks: vec!["third_party/erlang/mix.lock".to_owned()], - ..resolved - }; - assert_eq!(hex_backend(&mix), Some(HexBackend::Mix)); - } - - #[test] - fn the_repos_own_dart_hub_becomes_a_pub_set() { - let module = "pub = use_extension(\"//dart:extensions.bzl\", \"pub\", dev_dependency = True)\npub.from_lock(name = \"dx_dart_deps\", lock = \"//third_party/dart:pubspec.lock\")\n"; - let registry = discover(module, &|_: &str| Err("none".to_owned())) - .expect("discovers") - .expect("registry"); - let set = registry.find("dx_dart_deps").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Pub); - assert_eq!(set.locks, ["third_party/dart/pubspec.lock"]); - } - - #[test] - fn conan_extensions_become_conan_sets() { - let module = "conan = use_extension(\"//build/conan:conan_deps_module_extension.bzl\", \"conan_extension\")\n"; - let read = |path: &str| match path { - "build/conanfile.py" => Ok(String::new()), - _ => Err("none".to_owned()), - }; - let registry = discover(module, &read) - .expect("discovers") - .expect("registry"); - let set = registry.find("conan").expect("set"); - assert_eq!(set.ecosystem, Ecosystem::Conan); - assert_eq!(set.manifests, ["build/conanfile.py"]); - assert_eq!(set.locks, ["build/conan.lock"]); - } - - #[test] - fn bundle_fetches_become_ruby_sets() { - let module = "ruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(gemfile = \"//r:Gemfile\", gemfile_lock = \"//r:Gemfile.lock\")\n"; - let registry = found(module, &[]); - let set = registry.find("bundle").expect("bundle"); - assert_eq!(set.ecosystem, Ecosystem::Ruby); - assert_eq!(set.manifests, ["r/Gemfile"]); - assert_eq!(set.locks, ["r/Gemfile.lock"]); - } - - #[test] - fn paket_extensions_become_the_nuget_set() { - let module = "paket = use_extension(\"//third_party/dotnet:extension.bzl\", \"paket\")\n"; - let bzl = "load(\"//third_party/dotnet/deps:paket.main.bzl\", \"main\")\nload(\":framework.bzl\", \"framework\")\nload(\"//third_party/dotnet/deps:paket.docs.bzl\", \"docs\")\n"; - let registry = found(module, &[("third_party/dotnet/extension.bzl", bzl)]); - let set = registry.find("nuget").expect("nuget"); - assert_eq!(set.ecosystem, Ecosystem::Nuget); - assert_eq!(set.manifests, ["third_party/dotnet/paket.dependencies"]); - assert_eq!( - set.locks, - [ - "third_party/dotnet/paket.lock", - "third_party/dotnet/deps/paket.docs.bzl", - "third_party/dotnet/deps/paket.main.bzl", - ] - ); - let generated = - "main = use_extension(\"//x/deps:paket.main_extension.bzl\", \"main_extension\")\n"; - let registry = found(generated, &[]); - let set = registry.find("nuget").expect("nuget"); - assert_eq!(set.manifests, ["x/paket.dependencies"]); - assert_eq!(set.locks, ["x/paket.lock", "x/deps/paket.main.bzl"]); - } - - #[test] - fn included_module_files_are_followed() { - let module = "include(\"//tools:deps.MODULE.bazel\")\n"; - let included = format!( - "{NPM_EXT}npm.npm_translate_lock(name = \"tools\", pnpm_lock = \"//tools:pnpm-lock.yaml\")\n" - ); - let registry = found(module, &[("tools/deps.MODULE.bazel", &included)]); - assert_eq!(registry.names(), vec!["tools"]); - } - - #[test] - fn a_module_without_hubs_is_not_a_registry() { - assert_eq!(discovered("module(name = \"x\")\n", &[]), Ok(None)); - } - - #[test] - fn hub_problems_fail_closed() { - let duplicate = format!( - "{UV_EXT}uv.project(hub_name = \"pypi\", lock = \"//a/api:uv.lock\", pyproject = \"//a/api:pyproject.toml\")\nuv.project(hub_name = \"pypi\", lock = \"//b/api:uv.lock\", pyproject = \"//b/api:pyproject.toml\")\n" - ); - assert_eq!( - discovered(&duplicate, &[]), - Err(DependencySetsError::DuplicateName { - name: "pypi-api".to_owned() - }) - ); - let shared = format!( - "{NPM_EXT}npm.npm_translate_lock(name = \"a\", pnpm_lock = \"//:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"b\", pnpm_lock = \"//:pnpm-lock.yaml\")\n" - ); - assert!(matches!( - discovered(&shared, &[]), - Err(DependencySetsError::ConflictingOwnership { .. }) - )); - let computed = format!("{NPM_EXT}npm.npm_translate_lock(name = NAME)\n"); - assert!(matches!( - discovered(&computed, &[]), - Err(DependencySetsError::Module { .. }) - )); - assert!(matches!( - discovered( - "paket = use_extension(\"//d:extension.bzl\", \"paket\")\n", - &[] - ), - Err(DependencySetsError::Module { .. }) - )); - } - - #[test] - fn load_reads_the_workspace_module() { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-absent-"); - assert_eq!(load(&scratch.path().join("absent")).expect("absent"), None); - std::fs::write( - scratch.path().join(MODULE_BAZEL), - "go_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//:go.mod\")\n", - ) - .expect("module"); - let registry = load(scratch.path()).expect("loads").expect("registry"); - assert_eq!(registry.names(), vec!["go"]); - } - - #[test] - fn names_and_repositories_must_be_plain() { - for bad in ["", "a:b", "_a", "a b", "@a"] { - assert_eq!( - hub_set( - bad, - Ecosystem::Uv, - strings(&["a/pyproject.toml"]), - strings(&["a/uv.lock"]) - ), - Err(DependencySetsError::InvalidName { - name: bad.to_owned() - }), - "{bad}" - ); - } - assert_eq!( - hub_set( - "1crates", - Ecosystem::Cargo, - strings(&["a/Cargo.toml"]), - strings(&["a/Cargo.lock"]) - ), - Err(DependencySetsError::InvalidRepository { - name: "1crates".to_owned(), - repository: "1crates".to_owned(), - }) - ); - assert!( - hub_set( - "Crate_index.v2-x", - Ecosystem::Cargo, - strings(&["a/Cargo.toml"]), - strings(&["a/Cargo.lock"]) - ) - .is_ok() - ); - for bad in [ - "../up/pyproject.toml", - "a/../../pyproject.toml", - "/abs/pyproject.toml", - ] { - assert!( - matches!( - hub_set("a", Ecosystem::Uv, strings(&[bad]), strings(&["a/uv.lock"])), - Err(DependencySetsError::EscapingPath { .. }) - ), - "{bad}" - ); - } - } - - #[test] - fn two_same_ecosystem_sets_sort_by_name() { - let registry = two_uv(); - assert_eq!(registry.names(), vec!["frontend", "worker"]); - let frontend = registry.find("frontend").expect("frontend"); - assert_eq!(frontend.ecosystem, Ecosystem::Uv); - assert_eq!(frontend.manifests, vec!["apps/frontend/pyproject.toml"]); - assert_eq!(frontend.locks, vec!["apps/frontend/uv.lock"]); - assert_eq!(frontend.scopes, vec!["apps/frontend"]); - assert_eq!(ResolvedSet::of(frontend).dir, "apps/frontend"); - } - - #[test] - fn bare_selection_resolves_every_configured_set_full() { - let registry = two_uv(); - let resolved = resolve(®istry, &[]).expect("bare"); - assert_eq!(resolved.len(), 2); - assert_eq!(resolved[0].set.name, "frontend"); - assert_eq!(resolved[0].request, Selection::Full); - assert_eq!(resolved[1].set.name, "worker"); - assert_eq!(resolved[1].request, Selection::Full); - } - #[test] - fn names_scopes_and_packages_resolve() { - let registry = two_uv(); - let resolved = resolve(®istry, &strings(&["frontend"])).expect("name"); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].set.name, "frontend"); - let resolved = resolve(®istry, &strings(&["apps/frontend/app"])).expect("nested scope"); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].set.name, "frontend"); - let resolved = - resolve(®istry, &strings(&["//services/worker:target"])).expect("label scope"); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].set.name, "worker"); - let resolved = resolve(®istry, &strings(&["//..."])).expect("repo scope"); - assert_eq!(resolved.len(), 2); - let resolved = - resolve(®istry, &strings(&["frontend:anyio", "frontend:httpx"])).expect("packages"); - assert_eq!(resolved.len(), 1); - assert_eq!( - resolved[0].request, - Selection::Packages(vec!["anyio".to_owned(), "httpx".to_owned()]) - ); - let resolved = - resolve(®istry, &strings(&["frontend", "frontend:httpx"])).expect("full wins"); - assert_eq!(resolved[0].request, Selection::Full); - } - - #[test] - fn unknown_selectors_and_scopes_fail_closed() { - let registry = two_uv(); - assert!(matches!( - resolve(®istry, &strings(&["cargo"])), - Err(DependencySetsError::UnknownSelector { .. }) - )); - assert!(matches!( - resolve(®istry, &strings(&["docs/cli/README.md"])), - Err(DependencySetsError::NoOwningSet { .. }) - )); - assert!(matches!( - resolve(®istry, &strings(&["frontend:"])), - Err(DependencySetsError::InvalidPackage { .. }) - )); - assert!(matches!( - resolve(®istry, &strings(&["frontend:bad name"])), - Err(DependencySetsError::InvalidPackage { .. }) - )); - } - - #[test] - fn root_scope_owns_only_root_package_labels() { - let specs = |root: &'static [&'static str], lock: &'static [&'static str]| { - registry(&[ - ("root", Ecosystem::Npm, root, lock), - ( - "leaf", - Ecosystem::Uv, - &["apps/leaf/pyproject.toml"], - &["apps/leaf/uv.lock"], - ), - ]) - }; - let registry = specs(&["package.json"], &["pnpm-lock.yaml"]); - for target in ["//:node_modules", "//:", "."] { - assert_eq!(owning_sets(®istry, target), vec!["root"], "{target}"); - } - assert_eq!(owning_sets(®istry, "//foo:bar"), Vec::<&str>::new()); - assert_eq!(owning_sets(®istry, "//apps/leaf:t"), vec!["leaf"]); - assert_eq!(owning_sets(®istry, "//foo/..."), Vec::<&str>::new()); - assert_eq!(owning_sets(®istry, "//...").len(), 2); - assert_eq!(owning_sets(®istry, "//:MODULE.bazel").len(), 2); - let bare = specs(&["apps/root/package.json"], &["apps/root/pnpm-lock.yaml"]); - assert_eq!(owning_sets(&bare, "//:x"), Vec::<&str>::new()); - } - - #[test] - fn declared_manifests_and_locks_select_their_own_set() { - let registry = registry(&[ - ( - "cargo", - Ecosystem::Cargo, - &["rust/Cargo.toml"], - &["rust/Cargo.lock", "cargo-bazel-lock.json"], - ), - ( - "maven", - Ecosystem::Maven, - &["MODULE.bazel"], - &["third_party/jvm/maven_install.json"], - ), - ( - "npm", - Ecosystem::Npm, - &["web/package.json"], - &["web/pnpm-lock.yaml"], - ), - ("root", Ecosystem::Uv, &["pyproject.toml"], &["uv.lock"]), - ]); - assert_eq!( - owning_sets(®istry, "//:cargo-bazel-lock.json"), - vec!["cargo"] - ); - assert_eq!( - owning_sets(®istry, "third_party/jvm/maven_install.json"), - vec!["maven"] - ); - assert_eq!( - owning_sets(®istry, "//third_party/jvm:maven_install.json"), - vec!["maven"] - ); - assert_eq!(owning_sets(®istry, "//web:pnpm-lock.yaml"), vec!["npm"]); - assert_eq!(owning_sets(®istry, "//:other"), vec!["cargo", "root"]); - } - - #[test] - fn overlapping_scopes_resolve_to_every_owner() { - let registry = registry(&[ - ( - "mono", - Ecosystem::Uv, - &["apps/pyproject.toml"], - &["apps/uv.lock"], - ), - ( - "leaf", - Ecosystem::Uv, - &["apps/leaf/pyproject.toml"], - &["apps/leaf/uv.lock"], - ), - ]); - let resolved = resolve(®istry, &strings(&["apps/leaf/app"])).expect("overlap"); - assert_eq!(resolved.len(), 2); - let resolved = resolve(®istry, &strings(&["//apps/..."])).expect("recursive"); - assert_eq!(resolved.len(), 2); - } - - fn settings_workspace( - name: &str, - pyproject: &str, - config: Option<&str>, - ) -> dx_test_scratch::TempDir { - let scratch = dx_test_scratch::scratch(&format!("dx-dependency-sets-settings-{name}-")); - let workspace = scratch.path().to_path_buf(); - std::fs::create_dir_all(workspace.join("apps/frontend")).expect("mkdir"); - std::fs::write(workspace.join("apps/frontend/pyproject.toml"), pyproject) - .expect("pyproject"); - if let Some(config) = config { - std::fs::write( - workspace.join(config), - "exclude-newer = \"2020-01-01T00:00:00Z\"\n", - ) - .expect("uv.toml"); - } - scratch - } - - #[test] - fn uv_settings_allow_project_metadata_only() { - let registry = two_uv(); - let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); - let metadata = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n\n[tool.uv]\npackage = false\ndev-dependencies = []\n\n[tool.uv.sources]\nlocal = { path = \"../local\" }\n\n[tool.ruff]\nline-length = 100\n"; - let scratch = settings_workspace("ok", metadata, None); - let workspace = scratch.path().to_path_buf(); - assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); - let resolver = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n\n[tool.uv]\nexclude-newer = \"2020-01-01T00:00:00Z\"\nindex-url = \"https://example.invalid/simple\"\n"; - let scratch = settings_workspace("tool", resolver, None); - let workspace = scratch.path().to_path_buf(); - let error = check_backend_settings(&workspace, &frontend).expect_err("resolver settings"); - assert_eq!( - error, - DependencySetsError::UnsupportedSettings { - name: "frontend".to_owned(), - detail: "apps/frontend/pyproject.toml sets [tool.uv] exclude-newer, index-url" - .to_owned(), - } - ); - let message = error.to_string(); - assert!(message.contains("\"frontend\""), "{message}"); - assert!(message.contains("does not apply"), "{message}"); - } - - #[test] - fn uv_config_files_in_the_set_or_its_parents_are_unsupported() { - let registry = two_uv(); - let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); - let plain = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n"; - for (config, shown) in [ - ("apps/frontend/uv.toml", "apps/frontend/uv.toml exists"), - ("apps/uv.toml", "apps/uv.toml exists"), - ("uv.toml", "uv.toml exists"), - ] { - let scratch = settings_workspace(&config.replace('/', "-"), plain, Some(config)); - let workspace = scratch.path().to_path_buf(); - assert_eq!( - check_backend_settings(&workspace, &frontend), - Err(DependencySetsError::UnsupportedSettings { - name: "frontend".to_owned(), - detail: shown.to_owned(), - }), - "{config}" - ); - } - let scratch = settings_workspace("missing", plain, None); - let workspace = scratch.path().to_path_buf(); - std::fs::remove_file(workspace.join("apps/frontend/pyproject.toml")).expect("rm"); - assert!(matches!( - check_backend_settings(&workspace, &frontend), - Err(DependencySetsError::Read { .. }) - )); - } - - #[test] - fn parent_uv_workspaces_are_unsupported() { - let registry = two_uv(); - let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); - let plain = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n"; - for (parent, shown) in [ - ( - "apps/pyproject.toml", - "apps/pyproject.toml sets [tool.uv.workspace]", - ), - ("pyproject.toml", "pyproject.toml sets [tool.uv.workspace]"), - ] { - let scratch = settings_workspace(&parent.replace('/', "-"), plain, None); - let workspace = scratch.path().to_path_buf(); - std::fs::write( - workspace.join(parent), - "[tool.uv.workspace]\nmembers = [\"frontend\"]\n", - ) - .expect("parent pyproject"); - assert_eq!( - check_backend_settings(&workspace, &frontend), - Err(DependencySetsError::UnsupportedSettings { - name: "frontend".to_owned(), - detail: shown.to_owned(), - }), - "{parent}" - ); - } - let scratch = settings_workspace("plain-parent", plain, None); - let workspace = scratch.path().to_path_buf(); - std::fs::write(workspace.join("pyproject.toml"), plain).expect("parent pyproject"); - assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); - } - - #[test] - fn missing_files_fail_closed_with_paths() { - let registry = two_uv(); - let scratch = dx_test_scratch::scratch("dx-dependency-sets-missing-"); - let workspace = scratch.path().join("absent"); - let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); - assert!(matches!( - check_files(&workspace, &frontend, true), - Err(DependencySetsError::MissingFile { .. }) - )); - } - - #[cfg(unix)] - mod link_confinement { - use super::*; - - fn setup() -> (dx_test_scratch::TempDir, std::path::PathBuf, ResolvedSet) { - let outer = dx_test_scratch::scratch("dx-linkconf-"); - let workspace = outer.path().join("ws"); - std::fs::create_dir_all(&workspace).expect("ws"); - let registry = two_uv(); - let set = ResolvedSet::of(registry.find("frontend").expect("frontend")); - (outer, workspace, set) - } - - fn link_out(outer: &Path, workspace: &Path, rel: &str) -> std::path::PathBuf { - let outside = outer.join("outside.txt"); - std::fs::write(&outside, "x").expect("outside"); - let at = workspace.join(rel); - std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); - std::os::unix::fs::symlink(&outside, at).expect("link"); - outside - } - - fn is_outside(result: Result<(), DependencySetsError>) -> bool { - matches!(result, Err(DependencySetsError::OutsideWorkspace { .. })) - } - - #[test] - fn manifest_linking_outside_is_rejected() { - let (outer, workspace, set) = setup(); - link_out(outer.path(), &workspace, &set.manifests[0]); - assert!(is_outside(check_files(&workspace, &set, false))); - } - - #[test] - fn lock_linking_outside_is_rejected_with_and_without_need_lock() { - let (outer, workspace, set) = setup(); - for manifest in &set.manifests { - let at = workspace.join(manifest); - std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); - std::fs::write(at, "").expect("manifest"); - } - link_out(outer.path(), &workspace, &set.locks[0]); - assert!(is_outside(check_files(&workspace, &set, false))); - assert!(is_outside(check_files(&workspace, &set, true))); - } - - #[test] - fn dangling_lock_link_is_rejected_without_need_lock() { - let (outer, workspace, set) = setup(); - for manifest in &set.manifests { - let at = workspace.join(manifest); - std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); - std::fs::write(at, "").expect("manifest"); - } - let at = workspace.join(&set.locks[0]); - std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); - std::os::unix::fs::symlink(outer.path().join("nope"), at).expect("link"); - assert!(is_outside(check_files(&workspace, &set, false))); - } - - #[test] - fn lock_linking_inside_is_accepted() { - let (_outer, workspace, set) = setup(); - for manifest in &set.manifests { - let at = workspace.join(manifest); - std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); - std::fs::write(at, "").expect("manifest"); - } - let real = workspace.join("real.lock"); - std::fs::write(&real, "x").expect("real"); - let at = workspace.join(&set.locks[0]); - std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); - std::os::unix::fs::symlink(&real, at).expect("link"); - assert_eq!(check_files(&workspace, &set, true), Ok(())); - } - } - - fn two_npm() -> Registry { - registry(&[ - ( - "frontend", - Ecosystem::Npm, - &["apps/web/package.json"], - &["apps/web/pnpm-lock.yaml"], - ), - ( - "worker", - Ecosystem::Npm, - &["services/worker/package.json"], - &["services/worker/pnpm-lock.yaml"], - ), - ]) - } - - #[test] - fn npm_sets_load_under_their_own_names() { - let registry = two_npm(); - assert_eq!(registry.names(), vec!["frontend", "worker"]); - let frontend = registry.find("frontend").expect("frontend"); - assert_eq!(frontend.ecosystem, Ecosystem::Npm); - assert_eq!(frontend.manifests, vec!["apps/web/package.json"]); - assert_eq!(frontend.locks, vec!["apps/web/pnpm-lock.yaml"]); - assert_eq!(ResolvedSet::of(frontend).dir, "apps/web"); - assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm)); - assert_eq!(Ecosystem::Npm.name(), "npm"); - assert_eq!(Ecosystem::Npm.supported_check(), "check"); - assert_eq!(Ecosystem::Npm.supported_update(), "update"); - assert_eq!(Ecosystem::Npm.supported_audit(), "audit"); - assert_eq!( - SUPPORTED_ECOSYSTEMS, - "uv, npm, bazel, ruby, powershell, cargo, maven, nuget, go, cpan, pub, conan, julia, hex, composer, opam, swiftpm, cabal, dub, luarocks, ansible, bicep" - ); - } - - #[test] - fn advisory_families_follow_the_ecosystem_never_the_set_name() { - assert_eq!(Ecosystem::Uv.advisory_family(), Some("pypi")); - assert_eq!(Ecosystem::Npm.advisory_family(), Some("npm")); - assert_eq!(Ecosystem::Bazel.advisory_family(), None); - assert_eq!(Ecosystem::Ruby.advisory_family(), Some("rubygems")); - assert_eq!(Ecosystem::PowerShell.advisory_family(), None); - let registry = two_npm(); - for set in ®istry.sets { - assert_eq!(set.ecosystem.advisory_family(), Some("npm"), "{}", set.name); - } - } - - #[test] - fn manifests_must_carry_the_ecosystem_file_name() { - for (ecosystem, manifest, want) in [ - ("uv", "a/package.json", "pyproject.toml"), - ("uv", "a/pyproject.toml.bak", "pyproject.toml"), - ("npm", "a/pyproject.toml", "package.json"), - ("npm", "a/package.json5", "package.json"), - ("npm", "package.yaml", "package.json"), - ("bazel", "a/package.json", "MODULE.bazel"), - ("ruby", "a/gems.rb", "Gemfile"), - ("ruby", "a/Gemfile.lock", "Gemfile"), - ("cargo", "a/cargo.toml", "Cargo.toml"), - ("cargo", "a/Cargo.lock", "Cargo.toml"), - ( - "powershell", - "a/requirements.psd1", - "PSGallery.requirements.psd1", - ), - ] { - let kind = Ecosystem::parse(ecosystem).expect("known"); - let error = hub_set("a", kind, strings(&[manifest]), strings(&["a/lock"])) - .expect_err("wrong manifest name"); - assert_eq!( - error, - DependencySetsError::InvalidManifest { - name: "a".to_owned(), - path: manifest.to_owned(), - want, - ecosystem: Ecosystem::parse(ecosystem).expect("known").name(), - }, - "{ecosystem} {manifest}" - ); - assert!(error.to_string().contains(want), "{error}"); - } - assert!( - hub_set( - "root", - Ecosystem::Npm, - strings(&["package.json"]), - strings(&["pnpm-lock.yaml"]) - ) - .is_ok() - ); - } - - #[test] - fn npm_packages_allow_one_scope_and_lowercase_names() { - let registry = two_npm(); - for package in [ - "react", - "@types/node", - "lodash.merge", - "a-b_c", - "@scope/x.y", - ] { - let resolved = resolve(®istry, &[format!("frontend:{package}")]) - .unwrap_or_else(|error| panic!("{package}: {error}")); - assert_eq!( - resolved, - vec![ResolvedTarget { - set: ResolvedSet::of(registry.find("frontend").expect("frontend")), - request: Selection::Packages(vec![package.to_owned()]), - }] - ); - } - for package in [ - "React", - "@types", - "@/node", - "@types/", - "@a/b/c", - ".hidden", - "_private", - "a b", - "a;rm", - "--registry=x", - "pkg@1.0.0", - ] { - assert!( - matches!( - resolve(®istry, &[format!("frontend:{package}")]), - Err(DependencySetsError::InvalidPackage { .. }) - ), - "{package} must be rejected" - ); - } - } - - fn npm_workspace(name: &str) -> dx_test_scratch::TempDir { - let scratch = dx_test_scratch::scratch(&format!("dx-dependency-sets-npm-{name}-")); - let workspace = scratch.path().to_path_buf(); - std::fs::create_dir_all(workspace.join("apps/web")).expect("set dir"); - std::fs::write(workspace.join("apps/web/package.json"), "{}\n").expect("manifest"); - scratch - } - - #[test] - fn npm_sets_need_their_own_pnpm_lock_and_no_parent_pnpm_workspace() { - let registry = two_npm(); - let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); - let scratch = npm_workspace("plain"); - let workspace = scratch.path().to_path_buf(); - assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); - std::fs::write( - workspace.join("apps/web/pnpm-workspace.yaml"), - "packages: []\n", - ) - .expect("own workspace"); - assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); - for parent in ["apps/pnpm-workspace.yaml", "pnpm-workspace.yaml"] { - let scratch = npm_workspace(&parent.replace('/', "-")); - let workspace = scratch.path().to_path_buf(); - std::fs::write(workspace.join(parent), "packages: [\"apps/*\"]\n") - .expect("parent workspace"); - assert_eq!( - check_backend_settings(&workspace, &frontend), - Err(DependencySetsError::UnsupportedNpmLayout { - name: "frontend".to_owned(), - dir: "apps/web".to_owned(), - detail: format!("{parent} makes pnpm write the parent workspace lock"), - }), - "{parent}" - ); - } - let scratch = npm_workspace("root-only"); - let workspace = scratch.path().to_path_buf(); - std::fs::write( - workspace.join("pnpm-workspace.yaml"), - "packages:\n - \".\" # root\nallowBuilds: {}\n", - ) - .expect("root-only workspace"); - assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); - assert!(npm_ignore_workspace(&workspace, &frontend)); - std::fs::write( - workspace.join("apps/web/pnpm-workspace.yaml"), - "packages: []\n", - ) - .expect("own workspace"); - assert!(!npm_ignore_workspace(&workspace, &frontend)); - let scratch = npm_workspace("members"); - let workspace = scratch.path().to_path_buf(); - std::fs::write( - workspace.join("pnpm-workspace.yaml"), - "packages: [\"apps/*\"]\n", - ) - .expect("members workspace"); - assert!(!npm_ignore_workspace(&workspace, &frontend)); - let scratch = npm_workspace("other-lock"); - let workspace = scratch.path().to_path_buf(); - let mut other = frontend.clone(); - other.locks = vec!["apps/web/bun.lockb".to_owned()]; - assert_eq!( - check_backend_settings(&workspace, &other), - Err(DependencySetsError::UnsupportedNpmLayout { - name: "frontend".to_owned(), - dir: "apps/web".to_owned(), - detail: "locks must include apps/web/pnpm-lock.yaml, apps/web/yarn.lock, or apps/web/package-lock.json" - .to_owned(), - }) - ); - for (lock, backend) in [ - ("apps/web/yarn.lock", NpmBackend::Yarn), - ("apps/web/package-lock.json", NpmBackend::Npm), - ] { - other.locks = vec![lock.to_owned()]; - assert_eq!(check_backend_settings(&workspace, &other), Ok(()), "{lock}"); - assert_eq!(npm_backend(&other), Some(backend)); - } - } - - fn pinned() -> Registry { - registry(&[ - ( - "gems", - Ecosystem::Ruby, - &["apps/rb/Gemfile"], - &["apps/rb/Gemfile.lock"], - ), - ( - "modules", - Ecosystem::PowerShell, - &["tools/ps/PSGallery.requirements.psd1"], - &["tools/ps/PSGallery.lock.json"], - ), - ]) - } - - #[test] - fn cpan_module_names_allow_double_colons() { - for good in ["Moo", "Text::CSV", "Text::CSV_XS"] { - assert!(validate_package(Ecosystem::Cpan, good).is_ok(), "{good}"); - } - for bad in ["Text::", "::CSV", "Text:CSV", "Text::CSV-XS", ""] { - assert!(validate_package(Ecosystem::Cpan, bad).is_err(), "{bad}"); - } - } - - #[test] - fn ruby_and_powershell_sets_load_as_pinned_ecosystems() { - let registry = pinned(); - assert_eq!(registry.names(), vec!["gems", "modules"]); - let gems = registry.find("gems").expect("gems"); - assert_eq!(gems.ecosystem, Ecosystem::Ruby); - assert_eq!(ResolvedSet::of(gems).dir, "apps/rb"); - let modules = registry.find("modules").expect("modules"); - assert_eq!(modules.ecosystem, Ecosystem::PowerShell); - for (ecosystem, name, manifest) in [ - (Ecosystem::Ruby, "ruby", "Gemfile"), - ( - Ecosystem::PowerShell, - "powershell", - "PSGallery.requirements.psd1", - ), - ] { - assert_eq!(Ecosystem::parse(name), Some(ecosystem)); - assert_eq!(ecosystem.name(), name); - assert_eq!(ecosystem.manifest_name(), manifest); - assert_eq!(ecosystem.supported_check(), "pinned"); - assert_eq!(ecosystem.supported_update(), "pinned"); - assert_eq!(ecosystem.supported_audit(), "audit"); - assert_eq!( - ecosystem.supported_operations(), - "audit; check and update report pinned" - ); - } - assert_eq!(Ecosystem::Uv.supported_operations(), "check, update, audit"); - assert_eq!( - Ecosystem::Npm.supported_operations(), - "check, update, audit" - ); - assert_eq!(Ecosystem::parse("rubygems"), None); - assert_eq!(Ecosystem::parse("PowerShell"), None); - } - - #[test] - fn pinned_sets_must_declare_the_lock_beside_their_manifest() { - let registry = pinned(); - let scratch = dx_test_scratch::scratch("dx-dependency-sets-pinned-"); - let workspace = scratch.path().to_path_buf(); - for name in ["gems", "modules"] { - let set = ResolvedSet::of(registry.find(name).expect("set")); - assert_eq!(check_backend_settings(&workspace, &set), Ok(()), "{name}"); - } - for (ecosystem, manifest, lock, want) in [ - ( - "ruby", - "apps/rb/Gemfile", - "apps/rb/gems.locked", - "apps/rb/Gemfile.lock", - ), - ("ruby", "Gemfile", "other/Gemfile.lock", "Gemfile.lock"), - ( - "powershell", - "tools/ps/PSGallery.requirements.psd1", - "tools/ps/lock.json", - "tools/ps/PSGallery.lock.json", - ), - ] { - let kind = Ecosystem::parse(ecosystem).expect("known"); - let set = ResolvedSet::of( - &hub_set("a", kind, strings(&[manifest]), strings(&[lock])).expect("valid"), - ); - let error = check_backend_settings(&workspace, &set).expect_err("missing lock"); - assert_eq!( - error, - DependencySetsError::MissingPinnedLock { - name: "a".to_owned(), - path: want.to_owned(), - ecosystem: Ecosystem::parse(ecosystem).expect("known").name(), - }, - "{ecosystem} {lock}" - ); - assert!(error.to_string().contains(want), "{error}"); - } - } - - #[test] - fn pinned_packages_use_the_plain_charset() { - let registry = pinned(); - for (set, package) in [ - ("gems", "rspec-core"), - ("gems", "nokogiri"), - ("gems", "Ruby_Gem.2"), - ("modules", "Pester"), - ("modules", "Az.Accounts"), - ] { - let resolved = resolve(®istry, &[format!("{set}:{package}")]) - .unwrap_or_else(|error| panic!("{set}:{package}: {error}")); - assert_eq!( - resolved[0].request, - Selection::Packages(vec![package.to_owned()]) - ); - } - for (set, package, reason) in [ - ("gems", "a b", RUBY_PACKAGE_CHARSET), - ("gems", "rails@7", RUBY_PACKAGE_CHARSET), - ("gems", "a;rm", RUBY_PACKAGE_CHARSET), - ("modules", "Az/Accounts", POWERSHELL_PACKAGE_CHARSET), - ("modules", "Az Accounts", POWERSHELL_PACKAGE_CHARSET), - ] { - assert_eq!( - resolve(®istry, &[format!("{set}:{package}")]), - Err(DependencySetsError::InvalidPackage { - set: set.to_owned(), - package: package.to_owned(), - reason, - }), - "{set}:{package}" - ); - } - } - - fn cargo_sets() -> Registry { - registry(&[ - ( - "crates", - Ecosystem::Cargo, - &["apps/rs/Cargo.toml"], - &["apps/rs/Cargo.lock", "apps/rs/cargo-bazel-lock.json"], - ), - ( - "tool_crates", - Ecosystem::Cargo, - &["tools/rs/Cargo.toml"], - &["tools/rs/Cargo.lock"], - ), - ]) - } - - const CARGO_EXT: &str = - "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\n"; - - const CARGO_HUBS: &str = "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//apps/rs:Cargo.lock\", lockfile = \"//apps/rs:cargo-bazel-lock.json\", manifests = [\"//apps/rs:Cargo.toml\"])\ncrate.from_cargo(name = \"tool_crates\", cargo_lockfile = \"//tools/rs:Cargo.lock\", manifests = [\"//tools/rs:Cargo.toml\"])\nuse_repo(crate, \"crates\", \"tool_crates\")\n"; - - fn cargo_hub_detail(module: &str, set: &ResolvedSet) -> String { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-cargo-hub-"); - std::fs::write(scratch.path().join(MODULE_BAZEL), module).expect("module"); - match check_backend_settings(scratch.path(), set) { - Err(DependencySetsError::UnsupportedCargoHub { detail, .. }) => detail, - other => panic!("expected UnsupportedCargoHub, got {other:?}"), - } - } - - #[test] - fn cargo_hubs_resolve_by_their_visible_name_and_refuse_everything_else() { - let registry = cargo_sets(); - let crates = ResolvedSet::of(registry.find("crates").expect("crates")); - let scratch = dx_test_scratch::scratch("dx-dependency-sets-cargo-visible-"); - let hub = |import: &str| { - format!( - "{CARGO_EXT}crate.from_cargo(name = \"crates\", cargo_lockfile = \"//apps/rs:Cargo.lock\", lockfile = \"//apps/rs:cargo-bazel-lock.json\", manifests = [\"//apps/rs:Cargo.toml\"])\n{import}" - ) - }; - for (import, visible) in [ - ("use_repo(crate, \"crates\")\n", "crates"), - ("use_repo(crate, deps = \"crates\")\n", "deps"), - ] { - std::fs::write(scratch.path().join(MODULE_BAZEL), hub(import)).expect("module"); - assert_eq!(check_backend_settings(scratch.path(), &crates), Ok(())); - assert_eq!( - cargo_fetch_repository(scratch.path(), &crates).as_deref(), - Some(visible) - ); - } - assert!(cargo_hub_detail(CARGO_EXT, &crates).contains("no crate.from_cargo")); - assert!(cargo_hub_detail(&hub(""), &crates).contains("use_repo")); - let two = hub("use_repo(crate, \"crates\")\n").replace( - "use_repo", - "crate.from_cargo(name = \"crates\", cargo_lockfile = \"//apps/rs:Cargo.lock\", manifests = [\"//apps/rs:Cargo.toml\"])\nuse_repo", - ); - assert!(cargo_hub_detail(&two, &crates).contains("2 crate.from_cargo")); - let mismatch = hub("use_repo(crate, \"crates\")\n") - .replace("//apps/rs:Cargo.lock", "//other:Cargo.lock"); - assert!(cargo_hub_detail(&mismatch, &crates).contains("cargo_lockfile")); - let unlisted = - hub("use_repo(crate, \"crates\")\n").replace("cargo-bazel-lock.json", "other.json"); - assert!(cargo_hub_detail(&unlisted, &crates).contains("not listed in the set's locks")); - let mut stray = crates.clone(); - stray.manifests.push("stray/Cargo.toml".to_owned()); - assert!( - cargo_hub_detail(&hub("use_repo(crate, \"crates\")\n"), &stray) - .contains("stray/Cargo.toml") - ); - } - - #[test] - fn cargo_sets_may_span_directories() { - let set = hub_set( - "a", - Ecosystem::Cargo, - strings(&["a/Cargo.toml", "b/Cargo.toml"]), - strings(&["a/Cargo.lock"]), - ) - .expect("valid"); - assert_eq!(set.manifests.len(), 2); - assert_eq!(set.scopes, ["a", "b"]); - } - - #[test] - fn cargo_sets_load_with_their_repository() { - let registry = cargo_sets(); - assert_eq!(registry.names(), vec!["crates", "tool_crates"]); - let crates = registry.find("crates").expect("crates"); - assert_eq!(crates.ecosystem, Ecosystem::Cargo); - assert_eq!(crates.repository.as_deref(), Some("crates")); - let resolved = ResolvedSet::of(crates); - assert_eq!(resolved.dir, "apps/rs"); - assert_eq!(resolved.repository.as_deref(), Some("crates")); - assert_eq!(Ecosystem::parse("cargo"), Some(Ecosystem::Cargo)); - assert_eq!(Ecosystem::Cargo.name(), "cargo"); - assert_eq!(Ecosystem::Cargo.manifest_name(), "Cargo.toml"); - assert_eq!(Ecosystem::Cargo.supported_check(), "check"); - assert_eq!(Ecosystem::Cargo.supported_update(), "update"); - assert_eq!( - Ecosystem::Cargo.supported_operations(), - "check, update, audit" - ); - assert_eq!(Ecosystem::Cargo.advisory_family(), Some("cargo")); - assert!(Ecosystem::Cargo.uses_repository()); - for other in [ - Ecosystem::Uv, - Ecosystem::Npm, - Ecosystem::Bazel, - Ecosystem::Ruby, - Ecosystem::PowerShell, - ] { - assert!(!other.uses_repository(), "{}", other.name()); - } - let uv = two_uv(); - assert_eq!(uv.find("frontend").expect("frontend").repository, None); - } - - #[test] - fn cargo_sets_must_declare_cargo_lock_beside_the_manifest() { - let registry = cargo_sets(); - let scratch = dx_test_scratch::scratch("dx-dependency-sets-cargo-"); - let workspace = scratch.path().to_path_buf(); - std::fs::write(workspace.join(MODULE_BAZEL), CARGO_HUBS).expect("module"); - for name in ["crates", "tool_crates"] { - let set = ResolvedSet::of(registry.find(name).expect("set")); - assert_eq!(check_backend_settings(&workspace, &set), Ok(()), "{name}"); - } - let set = ResolvedSet::of( - &hub_set( - "a", - Ecosystem::Cargo, - strings(&["a/Cargo.toml"]), - strings(&["a/cargo-bazel-lock.json"]), - ) - .expect("valid"), - ); - assert_eq!( - check_backend_settings(&workspace, &set), - Err(DependencySetsError::MissingPinnedLock { - name: "a".to_owned(), - path: "a/Cargo.lock".to_owned(), - ecosystem: "cargo", - }) - ); - } - - #[test] - fn cargo_packages_use_the_crate_charset() { - let registry = cargo_sets(); - for package in ["serde", "serde_json", "tokio-util", "Inflector"] { - let resolved = resolve(®istry, &[format!("crates:{package}")]) - .unwrap_or_else(|error| panic!("{package}: {error}")); - assert_eq!( - resolved[0].request, - Selection::Packages(vec![package.to_owned()]) - ); - } - for package in ["serde@1.0.0", "a.b", "a b", "a;rm", "--locked", "_a", "a/b"] { - assert_eq!( - resolve(®istry, &[format!("crates:{package}")]), - Err(DependencySetsError::InvalidPackage { - set: "crates".to_owned(), - package: package.to_owned(), - reason: CARGO_PACKAGE_CHARSET, - }), - "{package}" - ); - } - } - const MAVEN_MODULE_TEXT: &str = "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"tool_maven\", lock_file = \"//tools:tool_install.json\")\nuse_repo(maven, \"maven\", \"tool_maven\")\n"; - - fn maven_sets() -> Registry { - registry(&[ - ( - "maven", - Ecosystem::Maven, - &["MODULE.bazel"], - &["third_party/maven_install.json"], - ), - ( - "tool_maven", - Ecosystem::Maven, - &["MODULE.bazel"], - &["tools/tool_install.json"], - ), - ( - "root", - Ecosystem::Bazel, - &["MODULE.bazel"], - &["MODULE.bazel.lock"], - ), - ]) - } - - const SIGNED_LOCK: &str = "{\"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY\":\"x\",\"__INPUT_ARTIFACTS_HASH\":1,\"__RESOLVED_ARTIFACTS_HASH\":2}"; - - fn maven_workspace(module: &str, lock: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-maven-"); - let root = scratch.path(); - std::fs::write(root.join("MODULE.bazel"), module).expect("module"); - std::fs::create_dir_all(root.join("third_party")).expect("dir"); - std::fs::write(root.join("third_party/maven_install.json"), lock).expect("lock"); - let registry = maven_sets(); - let set = ResolvedSet::of(registry.find("maven").expect("maven")); - (scratch, set) - } - - fn hub_detail(result: Result<(), DependencySetsError>) -> String { - match result { - Err(DependencySetsError::UnsupportedMavenHub { detail, .. }) => detail, - other => panic!("expected UnsupportedMavenHub, got {other:?}"), - } - } - - #[test] - fn maven_sets_share_the_root_module_but_own_their_locks() { - let registry = maven_sets(); - assert_eq!(registry.names(), vec!["maven", "root", "tool_maven"]); - let jvm = registry.find("maven").expect("maven"); - assert_eq!(jvm.ecosystem, Ecosystem::Maven); - assert_eq!(jvm.repository.as_deref(), Some("maven")); - assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven)); - assert_eq!(Ecosystem::Maven.manifest_name(), "MODULE.bazel"); - assert_eq!( - Ecosystem::Maven.supported_operations(), - "check, update, audit" - ); - assert_eq!(Ecosystem::Maven.advisory_family(), Some("maven")); - assert!(Ecosystem::Maven.uses_repository()); - assert!(matches!( - try_registry(&[ - ( - "a", - Ecosystem::Maven, - &["MODULE.bazel"], - &["third_party/maven_install.json"], - ), - ( - "b", - Ecosystem::Maven, - &["MODULE.bazel"], - &["third_party/maven_install.json"], - ), - ]), - Err(DependencySetsError::ConflictingOwnership { .. }) - )); - let two_uv = two_uv(); - assert!(two_uv.find("frontend").is_some()); - } - - #[test] - fn maven_packages_are_group_and_artifact() { - let registry = maven_sets(); - assert!(resolve(®istry, &["maven:com.google.guava:guava".to_owned()]).is_ok()); - for package in ["guava", "a:", ":b", "a:b:1.0", "a b:c", "a;x:b"] { - assert_eq!( - resolve(®istry, &[format!("maven:{package}")]), - Err(DependencySetsError::InvalidPackage { - set: "maven".to_owned(), - package: package.to_owned(), - reason: MAVEN_PACKAGE_CHARSET, - }), - "{package}" - ); - } - } - - #[test] - fn maven_hub_must_exist_in_the_root_module() { - let (scratch, set) = maven_workspace(MAVEN_MODULE_TEXT, ""); - let root = scratch.path(); - assert_eq!(check_backend_settings(root, &set), Ok(())); - let cases = [ - ( - "bazel_dep(name = \"x\", version = \"1\")\n", - "no maven.install", - ), - ( - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\n", - "use_repo", - ), - ( - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nuse_repo(maven, m2 = \"maven\")\n", - "use_repo", - ), - ( - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\")\nuse_repo(maven, \"maven\")\n", - "literal lock_file", - ), - ( - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//other:lock.json\")\nuse_repo(maven, \"maven\")\n", - "not listed", - ), - ( - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = L)\nuse_repo(maven, \"maven\")\n", - "literal name and lock_file", - ), - ( - "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nuse_repo(maven, \"maven\")\n", - "2 maven.install", - ), - ]; - for (module, want) in cases { - std::fs::write(root.join("MODULE.bazel"), module).expect("module"); - let detail = hub_detail(check_backend_settings(root, &set)); - assert!(detail.contains(want), "{detail} should contain {want}"); - } - std::fs::write(root.join("MODULE.bazel"), MAVEN_MODULE_TEXT).expect("module"); - std::fs::remove_file(root.join("third_party/maven_install.json")).expect("rm"); - assert!(hub_detail(check_backend_settings(root, &set)).contains("does not exist")); - } - - #[test] - fn maven_check_needs_a_signed_lock_and_fail_if_repin_required() { - let (scratch, set) = maven_workspace(MAVEN_MODULE_TEXT, SIGNED_LOCK); - let root = scratch.path(); - assert_eq!(check_lock_ready(root, &set), Ok(())); - let lock = root.join("third_party/maven_install.json"); - for bad in [ - "", - "{}", - "not json", - "[1]", - "{\"__INPUT_ARTIFACTS_HASH\":1}", - "{\"dependency_tree\":{}}", - ] { - std::fs::write(&lock, bad).expect("lock"); - assert!( - hub_detail(check_lock_ready(root, &set)).contains("signatures"), - "{bad}" - ); - } - std::fs::write(&lock, SIGNED_LOCK).expect("lock"); - for policy in ["False", "FLAG"] { - let module = MAVEN_MODULE_TEXT.replacen( - "maven_install.json\")", - &format!("maven_install.json\", fail_if_repin_required = {policy})"), - 1, - ); - std::fs::write(root.join("MODULE.bazel"), module).expect("module"); - assert!(hub_detail(check_lock_ready(root, &set)).contains("fail_if_repin_required")); - } - let module = MAVEN_MODULE_TEXT.replacen( - "maven_install.json\")", - "maven_install.json\", fail_if_repin_required = True)", - 1, - ); - std::fs::write(root.join("MODULE.bazel"), module).expect("module"); - assert_eq!(check_lock_ready(root, &set), Ok(())); - let registry = two_uv(); - let uv = ResolvedSet::of(registry.find("frontend").expect("frontend")); - assert_eq!(check_lock_ready(root, &uv), Ok(())); - } - - const NUGET_LOCKS: [&str; 2] = [ - "third_party/dotnet/paket.lock", - "third_party/dotnet/deps/paket.main.bzl", - ]; - - fn nuget_set(locks: &[&str]) -> DependencySet { - hub_set( - "dotnet", - Ecosystem::Nuget, - strings(&["third_party/dotnet/paket.dependencies"]), - strings(locks), - ) - .expect("valid") - } - - const NUGET_MODULE: &str = "module(name = \"consumer\")\n"; - - fn nuget_workspace( - locks: &[&str], - module: &str, - dependencies: &str, - ) -> (dx_test_scratch::TempDir, ResolvedSet) { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-nuget-"); - let root = scratch.path(); - std::fs::write(root.join("MODULE.bazel"), module).expect("module"); - std::fs::create_dir_all(root.join("third_party/dotnet/deps")).expect("dir"); - std::fs::write( - root.join("third_party/dotnet/paket.dependencies"), - dependencies, - ) - .expect("dependencies"); - (scratch, ResolvedSet::of(&nuget_set(locks))) - } - - fn nuget_detail(result: Result<(), DependencySetsError>) -> String { - match result { - Err(DependencySetsError::UnsupportedNugetLayout { detail, .. }) => detail, - other => panic!("expected UnsupportedNugetLayout, got {other:?}"), - } - } - - #[test] - fn nuget_sets_load_with_paket_manifests() { - let set = nuget_set(&NUGET_LOCKS); - let registry = Registry { - sets: vec![set.clone()], - }; - let dotnet = &set; - assert_eq!(dotnet.ecosystem, Ecosystem::Nuget); - assert_eq!(Ecosystem::parse("nuget"), Some(Ecosystem::Nuget)); - assert_eq!(Ecosystem::Nuget.manifest_name(), "paket.dependencies"); - assert_eq!( - Ecosystem::Nuget.supported_operations(), - "check, update, audit" - ); - assert_eq!(Ecosystem::Nuget.advisory_family(), Some("nuget")); - assert!(!Ecosystem::Nuget.uses_repository()); - assert!(matches!( - hub_set( - "dotnet", - Ecosystem::Nuget, - strings(&["third_party/dotnet/packages.config"]), - strings(&NUGET_LOCKS), - ), - Err(DependencySetsError::InvalidManifest { .. }) - )); - assert!(resolve(®istry, &["dotnet:Newtonsoft.Json".to_owned()]).is_ok()); - for package in ["a b", "a;b", "a/b"] { - assert_eq!( - resolve(®istry, &[format!("dotnet:{package}")]), - Err(DependencySetsError::InvalidPackage { - set: "dotnet".to_owned(), - package: package.to_owned(), - reason: NUGET_PACKAGE_CHARSET, - }), - "{package}" - ); - } - } - - #[test] - fn nuget_layout_accepts_one_folder_of_groups() { - let (scratch, set) = nuget_workspace( - &NUGET_LOCKS, - NUGET_MODULE, - "source https://api.nuget.org/v3/index.json\nnuget Newtonsoft.Json\n", - ); - assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); - } - - #[test] - fn nuget_layout_refuses_what_staging_cannot_reproduce() { - let dependencies = "source https://api.nuget.org/v3/index.json\n"; - let main = "third_party/dotnet/deps/paket.main.bzl"; - let lock = "third_party/dotnet/paket.lock"; - let notes: &[&str] = &[lock, "third_party/dotnet/deps/notes.txt"]; - let other: &[&str] = &[lock, main, "third_party/dotnet/other/paket.docs.bzl"]; - let extension: &[&str] = &[ - lock, - main, - "third_party/dotnet/deps/paket.main_extension.bzl", - ]; - let cases: [(&[&str], &str, &str, &str); 4] = [ - (notes, NUGET_MODULE, dependencies, "neither paket.lock"), - (other, NUGET_MODULE, dependencies, "share one folder"), - ( - extension, - NUGET_MODULE, - dependencies, - "paket2bazel extension", - ), - ( - &NUGET_LOCKS, - NUGET_MODULE, - "source ../local-feed\n", - "not http(s)", - ), - ]; - let (scratch, set) = nuget_workspace(&[main], NUGET_MODULE, dependencies); - assert!(matches!( - check_backend_settings(scratch.path(), &set), - Err(DependencySetsError::MissingPinnedLock { .. }) - )); - for (locks, module, dependencies, want) in cases { - let (scratch, set) = nuget_workspace(locks, module, dependencies); - let detail = nuget_detail(check_backend_settings(scratch.path(), &set)); - assert!(detail.contains(want), "{want}: {detail}"); - } - } - - const GO_SUM: [&str; 1] = ["third_party/go/go.sum"]; - - fn go_set(manifest: &str, locks: &[&str]) -> Result { - hub_set("go", Ecosystem::Go, strings(&[manifest]), strings(locks)) - } - - const GO_MODULE: &str = "bazel_dep(name = \"rules_go\", version = \"0.50.1\")\n"; - - const GO_MOD_TEXT: &str = - "module example.com/m\n\ngo 1.22\n\nrequire github.com/google/go-cmp v0.7.0\n"; - - fn go_workspace( - locks: &[&str], - module: &str, - go_mod: &str, - ) -> (dx_test_scratch::TempDir, ResolvedSet) { - let scratch = dx_test_scratch::scratch("dx-dependency-sets-go-"); - let root = scratch.path(); - std::fs::write(root.join("MODULE.bazel"), module).expect("module"); - std::fs::create_dir_all(root.join("third_party/go")).expect("dir"); - std::fs::write(root.join("third_party/go/go.mod"), go_mod).expect("go.mod"); - let set = go_set("third_party/go/go.mod", locks).expect("valid"); - (scratch, ResolvedSet::of(&set)) - } - - fn go_detail(result: Result<(), DependencySetsError>) -> String { - match result { - Err(DependencySetsError::UnsupportedGoLayout { detail, .. }) => detail, - other => panic!("expected UnsupportedGoLayout, got {other:?}"), - } - } - - #[test] - fn go_sets_load_with_go_mod_manifests() { - let registry = Registry { - sets: vec![go_set("third_party/go/go.mod", &GO_SUM).expect("valid")], - }; - let set = registry.find("go").expect("go"); - assert_eq!(set.ecosystem, Ecosystem::Go); - assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go)); - assert_eq!(Ecosystem::Go.manifest_name(), "go.mod"); - assert_eq!(Ecosystem::Go.supported_operations(), "check, update, audit"); - assert_eq!(Ecosystem::Go.advisory_family(), Some("go")); - assert!(!Ecosystem::Go.uses_repository()); - assert!(matches!( - go_set("third_party/go/go.work", &GO_SUM), - Err(DependencySetsError::InvalidManifest { .. }) - )); - assert!(resolve(®istry, &["go:github.com/google/go-cmp".to_owned()]).is_ok()); - assert!(matches!( - resolve(®istry, &["go:a b".to_owned()]), - Err(DependencySetsError::InvalidPackage { .. }) - )); - } - - #[test] - fn go_layout_accepts_one_go_mod_with_go_sum() { - let (scratch, set) = go_workspace(&GO_SUM, GO_MODULE, GO_MOD_TEXT); - assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); - } - - #[test] - fn go_layout_refuses_what_staging_cannot_reproduce() { - let (scratch, set) = go_workspace(&["third_party/go/go.work.sum"], GO_MODULE, GO_MOD_TEXT); - assert!(matches!( - check_backend_settings(scratch.path(), &set), - Err(DependencySetsError::MissingPinnedLock { .. }) - )); - let extra: &[&str] = &["third_party/go/go.sum", "third_party/go/go.work.sum"]; - let replaced = format!("{GO_MOD_TEXT}\nreplace example.com/x => ../x\n"); - let renamed = "bazel_dep(name = \"rules_go\", version = \"0.50.1\", repo_name = \"io_bazel_rules_go\")\n"; - let with_env = format!( - "{GO_MODULE}\ngo_deps.config(go_env = {{\"GOPRIVATE\": \"corp.example.com\"}})\n" - ); - let cases: [(&[&str], &str, &str, &str); 6] = [ - (&GO_SUM, with_env.as_str(), GO_MOD_TEXT, "go_env"), - (extra, GO_MODULE, GO_MOD_TEXT, "exactly"), - (&GO_SUM, GO_MODULE, replaced.as_str(), "local path"), - (&GO_SUM, GO_MODULE, "not a go.mod {", "cannot parse"), - (&GO_SUM, "", GO_MOD_TEXT, "bazel_dep(name = \"rules_go\")"), - (&GO_SUM, renamed, GO_MOD_TEXT, "repo_name"), - ]; - for (locks, module, go_mod, want) in cases { - let (scratch, set) = go_workspace(locks, module, go_mod); - let detail = go_detail(check_backend_settings(scratch.path(), &set)); - assert!(detail.contains(want), "{want}: {detail}"); - } - } -} +#[path = "dependency_sets_tests.rs"] +mod tests; diff --git a/cli/adopt/src/dependency_sets_tests.rs b/cli/adopt/src/dependency_sets_tests.rs new file mode 100644 index 0000000000..7ee3c0e038 --- /dev/null +++ b/cli/adopt/src/dependency_sets_tests.rs @@ -0,0 +1,1991 @@ +use super::*; + +type Spec<'a> = (&'a str, Ecosystem, &'a [&'a str], &'a [&'a str]); + +fn strings(words: &[&str]) -> Vec { + words.iter().map(|word| (*word).to_owned()).collect() +} + +fn try_registry(specs: &[Spec<'_>]) -> Result { + let mut sets = Vec::new(); + for (name, ecosystem, manifests, locks) in specs { + sets.push(hub_set( + name, + *ecosystem, + strings(manifests), + strings(locks), + )?); + } + check_conflicts(&sets)?; + sets.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Registry { sets }) +} + +fn registry(specs: &[Spec<'_>]) -> Registry { + try_registry(specs).expect("valid registry") +} + +fn two_uv() -> Registry { + registry(&[ + ( + "frontend", + Ecosystem::Uv, + &["apps/frontend/pyproject.toml"], + &["apps/frontend/uv.lock"], + ), + ( + "worker", + Ecosystem::Uv, + &["services/worker/pyproject.toml"], + &["services/worker/uv.lock"], + ), + ]) +} + +fn discovered(root: &str, files: &[(&str, &str)]) -> Result, DependencySetsError> { + let files: BTreeMap = files + .iter() + .map(|(path, text)| ((*path).to_owned(), (*text).to_owned())) + .collect(); + discover(root, &|rel: &str| { + files + .get(rel) + .cloned() + .ok_or_else(|| format!("{rel}: missing")) + }) +} + +fn found(root: &str, files: &[(&str, &str)]) -> Registry { + discovered(root, files) + .expect("valid hubs") + .expect("hubs present") +} + +const CRATE_EXT: &str = + "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\n"; +const MAVEN_EXT: &str = + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\n"; +const NPM_EXT: &str = "npm = use_extension(\"@aspect_rules_js//npm:extensions.bzl\", \"npm\")\n"; +const UV_EXT: &str = "uv = use_extension(\"@rules_py//uv:extensions.bzl\", \"uv\")\n"; + +#[test] +fn cargo_hubs_become_sets_with_the_lock_dir_manifest_first() { + let module = format!( + "{CRATE_EXT}crate.from_cargo(name = \"dx_crates\", cargo_lockfile = \"//b:Cargo.lock\", lockfile = \"//:cargo-bazel-lock.json\", manifests = [\"//a:Cargo.toml\", \"//b:Cargo.toml\"])\ncrate.from_cargo(name = \"nolock\", manifests = [\"//c:Cargo.toml\"])\n" + ); + let registry = found(&module, &[]); + assert_eq!(registry.names(), vec!["dx_crates"]); + let set = registry.find("dx_crates").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Cargo); + assert_eq!(set.manifests, ["b/Cargo.toml", "a/Cargo.toml"]); + assert_eq!(set.locks, ["b/Cargo.lock", "cargo-bazel-lock.json"]); + assert_eq!(set.scopes, [".", "a", "b"]); + assert_eq!(set.repository.as_deref(), Some("dx_crates")); + assert_eq!(ResolvedSet::of(set).dir, "b"); +} + +#[test] +fn maven_installs_with_a_lock_file_become_sets() { + let module = format!( + "{MAVEN_EXT}maven.install(lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"nolock\")\n" + ); + let registry = found(&module, &[]); + assert_eq!(registry.names(), vec!["maven"]); + let set = registry.find("maven").expect("maven"); + assert_eq!(set.ecosystem, Ecosystem::Maven); + assert_eq!(set.manifests, [MODULE_BAZEL]); + assert_eq!(set.locks, ["third_party/maven_install.json"]); + assert_eq!(set.scopes, ["third_party"]); + assert_eq!(set.repository.as_deref(), Some("maven")); +} + +#[test] +fn npm_translate_locks_become_sets() { + let module = format!( + "{NPM_EXT}npm.npm_translate_lock(name = \"web\", pnpm_lock = \"//apps/web:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"bare\")\n" + ); + let registry = found(&module, &[]); + assert_eq!(registry.names(), vec!["web"]); + let set = registry.find("web").expect("web"); + assert_eq!(set.ecosystem, Ecosystem::Npm); + assert_eq!(set.manifests, ["apps/web/package.json"]); + assert_eq!(set.locks, ["apps/web/pnpm-lock.yaml"]); + assert_eq!(set.repository, None); +} + +#[test] +fn uv_projects_are_named_by_hub_and_directory() { + let module = format!( + "{UV_EXT}uv.project(hub_name = \"pypi\", lock = \"//apps/api:uv.lock\", pyproject = \"//apps/api:pyproject.toml\")\nuv.project(hub_name = \"pypi\", lock = \"//:uv.lock\", pyproject = \"//:pyproject.toml\")\n" + ); + let registry = found(&module, &[]); + assert_eq!(registry.names(), vec!["pypi", "pypi-api"]); + let api = registry.find("pypi-api").expect("api"); + assert_eq!(api.ecosystem, Ecosystem::Uv); + assert_eq!(api.manifests, ["apps/api/pyproject.toml"]); + assert_eq!(api.locks, ["apps/api/uv.lock"]); + assert_eq!(registry.find("pypi").expect("root").scopes, ["."]); +} + +#[test] +fn go_deps_files_become_the_go_set() { + let module = "go_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//third_party/go:go.mod\")\n"; + let registry = found(module, &[]); + let set = registry.find("go").expect("go"); + assert_eq!(set.ecosystem, Ecosystem::Go); + assert_eq!(set.manifests, ["third_party/go/go.mod"]); + assert_eq!(set.locks, ["third_party/go/go.sum"]); +} + +#[test] +fn opam_packages_become_opam_sets() { + let module = "ocaml = use_extension(\"//ocaml/toolchain:extensions.bzl\", \"ocaml\")\nocaml.lock(name = \"app_opam\", lock = \"//ocaml/app:app.opam.locked\")\n"; + let registry = found(module, &[]); + let set = registry.find("app_opam").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Opam); + assert_eq!(set.manifests, ["ocaml/app/app.opam"]); + assert_eq!(set.locks, ["ocaml/app/app.opam.locked"]); + assert!(check_opam_layout(&ResolvedSet::of(set)).is_ok()); +} + +#[test] +fn haskell_packages_become_cabal_sets() { + let module = "haskell = use_extension(\"//haskell/toolchain:extensions.bzl\", \"haskell\")\nhaskell.lock(name = \"hackage\", lock = \"//third_party:haskell_deps.json\")\n"; + let registry = found(module, &[]); + let set = registry.find("hackage").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Cabal); + assert_eq!(set.manifests, ["third_party/cabal.project"]); + assert_eq!( + set.locks, + [ + "third_party/cabal.project.freeze", + "third_party/haskell_deps.json" + ] + ); +} + +#[test] +fn cabal_layout_compares_freeze_and_sidecar_versions() { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-cabal-"); + let root = scratch.path(); + std::fs::create_dir_all(root.join("hs")).expect("dir"); + std::fs::write( + root.join("hs/cabal.project.freeze"), + "constraints: any.base ==4.20.2.0,\n any.safe ==0.3.21\nindex-state: hackage.haskell.org 2026-09-30T23:43:34Z\n", + ) + .expect("freeze"); + let sidecar = |safe: &str| { + format!( + "{{\"packages\":[{{\"name\":\"base\",\"version\":\"4.20.2.0\"}},{{\"name\":\"safe\",\"version\":\"{safe}\"}}]}}" + ) + }; + let set = ResolvedSet { + name: "hackage".to_owned(), + ecosystem: Ecosystem::Cabal, + dir: "hs".to_owned(), + manifests: vec!["hs/cabal.project".to_owned()], + locks: vec![ + "hs/cabal.project.freeze".to_owned(), + "hs/haskell_deps.json".to_owned(), + ], + repository: None, + }; + std::fs::write(root.join("hs/haskell_deps.json"), sidecar("0.3.21")).expect("sidecar"); + assert!(check_cabal_layout(root, &set).is_ok()); + std::fs::write(root.join("hs/haskell_deps.json"), sidecar("0.3.20")).expect("sidecar"); + let message = check_cabal_layout(root, &set) + .expect_err("stale") + .to_string(); + assert!(message.contains("safe 0.3.20"), "{message}"); +} + +#[test] +fn cpan_installs_become_cpan_sets() { + let module = "cpan = use_extension(\"@rules_perl//perl/cpan:extensions.bzl\", \"cpan\")\ncpan.install(name = \"perl_cpan\", lock = \"//perl/tools:cpanfile.snapshot.lock.json\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("perl_cpan").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Cpan); + assert_eq!(set.manifests, ["perl/tools/cpanfile"]); + assert_eq!( + set.locks, + [ + "perl/tools/cpanfile.snapshot", + "perl/tools/cpanfile.snapshot.lock.json" + ] + ); +} + +const CPAN_SNAPSHOT: &str = "# carton snapshot format: version 1.0\nDISTRIBUTIONS\n Try-Tiny-0.31\n pathname: E/ET/ETHER/Try-Tiny-0.31.tar.gz\n"; + +fn cpan_workspace(lock: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-cpan-"); + let root = scratch.path(); + std::fs::create_dir_all(root.join("perl")).expect("dir"); + std::fs::write(root.join("perl/cpanfile.snapshot"), CPAN_SNAPSHOT).expect("snapshot"); + std::fs::write(root.join("perl/cpanfile.snapshot.lock.json"), lock).expect("lock"); + std::fs::write( + root.join("perl/BUILD.bazel"), + "load(\"@rules_perl//perl/cpan:cpan_compiler.bzl\", \"cpan_compiler\")\n\ncpan_compiler(\n name = \"compiler\",\n cpanfile = \"cpanfile\",\n lockfile = \"cpanfile.snapshot.lock.json\",\n)\n", + ) + .expect("build"); + let set = ResolvedSet { + name: "perl_cpan".to_owned(), + ecosystem: Ecosystem::Cpan, + dir: "perl".to_owned(), + manifests: vec!["perl/cpanfile".to_owned()], + locks: vec![ + "perl/cpanfile.snapshot".to_owned(), + "perl/cpanfile.snapshot.lock.json".to_owned(), + ], + repository: None, + }; + (scratch, set) +} + +#[test] +fn cpan_compiler_label_reads_the_build_file_beside_the_lock() { + let (scratch, set) = cpan_workspace("{}"); + assert_eq!( + cpan_compiler_label(scratch.path(), &set).as_deref(), + Some("//perl:compiler") + ); + std::fs::write(scratch.path().join("perl/BUILD.bazel"), "").expect("build"); + assert_eq!(cpan_compiler_label(scratch.path(), &set), None); +} + +#[test] +fn cpan_layout_accepts_a_lock_that_matches_the_snapshot() { + let (scratch, set) = cpan_workspace("{\"Try-Tiny\":{\"strip_prefix\":\"Try-Tiny-0.31\"}}"); + assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); +} + +#[test] +fn cpan_layout_rejects_a_stale_lock_json() { + let (scratch, set) = cpan_workspace("{\"Try-Tiny\":{\"strip_prefix\":\"Try-Tiny-0.30\"}}"); + let error = check_backend_settings(scratch.path(), &set).expect_err("stale"); + let message = error.to_string(); + assert!(message.contains("Try-Tiny-0.31"), "{message}"); + assert!(message.contains("run the cpan_compiler"), "{message}"); +} + +fn composer_workspace(digest_reference: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-composer-"); + let root = scratch.path(); + std::fs::create_dir_all(root.join("php")).expect("dir"); + std::fs::write( + root.join("php/composer.lock"), + "{\"packages\":[{\"name\":\"psr/log\",\"version\":\"3.0.0\",\"dist\":{\"reference\":\"aaa\"}}],\"packages-dev\":[]}", + ) + .expect("lock"); + std::fs::write( + root.join("php/composer.digests.json"), + format!("{{\"packages\":{{\"psr/log\":{{\"reference\":\"{digest_reference}\"}}}}}}"), + ) + .expect("digests"); + let set = ResolvedSet { + name: "php".to_owned(), + ecosystem: Ecosystem::Composer, + dir: "php".to_owned(), + manifests: vec!["php/composer.json".to_owned()], + locks: vec![ + "php/composer.lock".to_owned(), + "php/composer.digests.json".to_owned(), + ], + repository: None, + }; + (scratch, set) +} + +#[test] +fn composer_locks_become_composer_sets() { + let module = "composer = use_extension(\"@rules_dx//php/composer:extensions.bzl\", \"composer\")\ncomposer.lock(name = \"vendor\", lock = \"//php:composer.lock\", digests = \"//php:composer.digests.json\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("vendor").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Composer); + assert_eq!(set.manifests, ["php/composer.json"]); + assert_eq!( + set.locks, + ["php/composer.lock", "php/composer.digests.json"] + ); +} + +fn dub_workspace(digest_version: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-dub-"); + let root = scratch.path(); + std::fs::create_dir_all(root.join("d")).expect("dir"); + std::fs::write( + root.join("d/dub.selections.json"), + "{\"fileVersion\":1,\"versions\":{\"semver\":\"0.8.0\"}}", + ) + .expect("selections"); + std::fs::write( + root.join("d/dub.digests.json"), + format!("{{\"packages\":{{\"semver\":{{\"version\":\"{digest_version}\"}}}}}}"), + ) + .expect("digests"); + let set = ResolvedSet { + name: "dub".to_owned(), + ecosystem: Ecosystem::Dub, + dir: "d".to_owned(), + manifests: vec!["d/dub.json".to_owned()], + locks: vec![ + "d/dub.selections.json".to_owned(), + "d/dub.digests.json".to_owned(), + ], + repository: None, + }; + (scratch, set) +} + +#[test] +fn dub_locks_become_dub_sets() { + let module = "dub = use_extension(\"@rules_dx//d/dub:extensions.bzl\", \"dub\")\ndub.lock(name = \"dub_deps\", lock = \"//d:dub.selections.json\", digests = \"//d:dub.digests.json\")\n"; + let read = |path: &str| match path { + "d/dub.sdl" => Ok(String::new()), + _ => Err("none".to_owned()), + }; + let registry = discover(module, &read) + .expect("discovers") + .expect("registry"); + let set = registry.find("dub_deps").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Dub); + assert_eq!(set.manifests, ["d/dub.sdl"]); + assert_eq!(set.locks, ["d/dub.selections.json", "d/dub.digests.json"]); +} + +#[test] +fn luarocks_locks_become_luarocks_sets() { + let module = "luarocks = use_extension(\"@rules_dx//lua/luarocks:extensions.bzl\", \"luarocks\")\nluarocks.lock(name = \"rocks\", lock = \"//lua:luarocks.lock\", digests = \"//lua:luarocks.digests.json\")\n"; + let registry = found(module, &[]); + let set = registry.find("rocks").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::LuaRocks); + assert_eq!(set.manifests, ["lua/luarocks.lock"]); + assert_eq!( + set.locks, + ["lua/luarocks.lock", "lua/luarocks.digests.json"] + ); + assert!(check_backend_settings(Path::new("."), &ResolvedSet::of(set)).is_ok()); + assert_eq!(Ecosystem::parse("luarocks"), Some(Ecosystem::LuaRocks)); + assert_eq!(Ecosystem::LuaRocks.advisory_family(), None); + assert_eq!(Ecosystem::LuaRocks.supported_update(), "pinned"); + assert!(validate_package(Ecosystem::LuaRocks, "lua-cjson").is_ok()); +} + +#[test] +fn ansible_and_bicep_tags_become_sets() { + let module = "ansible = use_extension(\"//ansible:extensions.bzl\", \"ansible\")\nansible.lock(name = \"cols\", lock = \"//play:lock.json\")\nbicep = use_extension(\"//bicep:extensions.bzl\", \"bicep\")\nbicep.registry_module(name = \"storage\", ref = \"br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.0\", digest = \"sha256:abc\")\nbicep.registry_module(name = \"network\", ref = \"br:mcr.microsoft.com/bicep/avm/res/network/virtual-network:0.1.0\", digest = \"sha256:def\")\n"; + let registry = found(module, &[]); + let cols = registry.find("cols").expect("set"); + assert_eq!(cols.ecosystem, Ecosystem::Ansible); + assert_eq!(cols.manifests, ["MODULE.bazel"]); + assert_eq!(cols.locks, ["play/lock.json"]); + assert!(check_ansible_layout(&ResolvedSet::of(cols)).is_ok()); + let storage = registry.find("storage").expect("set"); + assert_eq!(storage.ecosystem, Ecosystem::Bicep); + assert!(storage.locks.is_empty()); + assert_eq!(Ecosystem::parse("bicep"), Some(Ecosystem::Bicep)); + assert_eq!(Ecosystem::Ansible.supported_update(), "update"); +} + +#[test] +fn dub_layout_accepts_matching_digests() { + let (scratch, set) = dub_workspace("0.8.0"); + assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); +} + +#[test] +fn dub_layout_rejects_a_stale_digest() { + let (scratch, set) = dub_workspace("0.7.0"); + let message = check_backend_settings(scratch.path(), &set) + .expect_err("stale") + .to_string(); + assert!(message.contains("semver"), "{message}"); + assert!(message.contains("0.7.0"), "{message}"); +} + +#[test] +fn composer_layout_accepts_matching_digests() { + let (scratch, set) = composer_workspace("aaa"); + assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); +} + +#[test] +fn composer_layout_rejects_a_stale_digest() { + let (scratch, set) = composer_workspace("bbb"); + let error = check_backend_settings(scratch.path(), &set).expect_err("stale"); + let message = error.to_string(); + assert!(message.contains("psr/log"), "{message}"); + assert!(message.contains("aaa"), "{message}"); +} + +#[test] +fn swift_from_package_tags_become_swiftpm_sets() { + let module = "swift_deps = use_extension(\"@rules_swift_package_manager//:extensions.bzl\", \"swift_deps\")\nswift_deps.from_package(resolved = \"//app:Package.resolved\", swift = \"//app:Package.swift\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("swiftpm").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::SwiftPm); + assert_eq!(set.manifests, ["app/Package.swift"]); + assert_eq!(set.locks, ["app/Package.resolved"]); +} + +#[test] +fn julia_from_manifest_tags_become_julia_sets() { + let module = "pkg = use_extension(\"@rules_julia//julia/pkg:extensions.bzl\", \"pkg\")\npkg.from_manifest(name = \"jl_deps\", lockfile = \"//third_party/julia:Manifest.bazel.json\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("jl_deps").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Julia); + assert_eq!(set.manifests, ["third_party/julia/Project.toml"]); + assert_eq!( + set.locks, + [ + "third_party/julia/Manifest.toml", + "third_party/julia/Manifest.bazel.json" + ] + ); +} + +#[test] +fn julia_layout_needs_both_locks_to_name_the_same_versions() { + let dir = std::env::temp_dir().join(format!("dx-julia-layout-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("dir"); + let manifest = "manifest_format = \"2.0\"\n[[deps.Glob]]\nversion = \"1.3.1\"\n[[deps.Dates]]\nuuid = \"x\"\n"; + std::fs::write(dir.join("Manifest.toml"), manifest).expect("write"); + std::fs::write( + dir.join("Manifest.bazel.json"), + "{\"Glob\": {\"version\": \"1.3.1\"}}", + ) + .expect("write"); + let set = ResolvedSet { + name: "jl".to_owned(), + ecosystem: Ecosystem::Julia, + dir: ".".to_owned(), + manifests: vec!["Project.toml".to_owned()], + locks: vec!["Manifest.toml".to_owned(), "Manifest.bazel.json".to_owned()], + repository: None, + }; + assert!(check_julia_layout(&dir, &set).is_ok()); + std::fs::write( + dir.join("Manifest.bazel.json"), + "{\"Glob\": {\"version\": \"1.3.0\"}}", + ) + .expect("write"); + let message = check_julia_layout(&dir, &set).unwrap_err().to_string(); + assert!(message.contains("Glob 1.3.0"), "{message}"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn pub_locks_become_pub_sets() { + let module = "pub = use_extension(\"@rules_dart//dart/pub:extensions.bzl\", \"pub\")\npub.from_lock(name = \"dart_deps\", lock = \"//third_party/dart:pubspec.lock\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("dart_deps").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Pub); + assert_eq!(set.manifests, ["third_party/dart/pubspec.yaml"]); + assert_eq!(set.locks, ["third_party/dart/pubspec.lock"]); +} + +#[test] +fn elixir_mix_tags_become_hex_sets() { + let module = "elixir = use_extension(\"//elixir/toolchain:extensions.bzl\", \"elixir\")\nelixir.lock(name = \"deps\", lock = \"//third_party/elixir:mix.lock\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("deps").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Hex); + assert_eq!(set.manifests, ["third_party/elixir/mix.exs"]); + assert_eq!(set.locks, ["third_party/elixir/mix.lock"]); +} + +#[test] +fn erlang_rebar_tags_become_rebar_hex_sets() { + let module = "erlang = use_extension(\"//erlang/toolchain:repos.bzl\", \"erlang\")\nerlang.rebar(name = \"deps\", lock = \"//third_party/erlang:rebar.lock\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("deps").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Hex); + assert_eq!(set.manifests, ["third_party/erlang/rebar.config"]); + assert_eq!(set.locks, ["third_party/erlang/rebar.lock"]); + let resolved = ResolvedSet::of(set); + assert_eq!(hex_backend(&resolved), Some(HexBackend::Rebar3)); + let mix = ResolvedSet { + locks: vec!["third_party/erlang/mix.lock".to_owned()], + ..resolved + }; + assert_eq!(hex_backend(&mix), Some(HexBackend::Mix)); +} + +#[test] +fn the_repos_own_dart_hub_becomes_a_pub_set() { + let module = "pub = use_extension(\"//dart:extensions.bzl\", \"pub\", dev_dependency = True)\npub.from_lock(name = \"dx_dart_deps\", lock = \"//third_party/dart:pubspec.lock\")\n"; + let registry = discover(module, &|_: &str| Err("none".to_owned())) + .expect("discovers") + .expect("registry"); + let set = registry.find("dx_dart_deps").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Pub); + assert_eq!(set.locks, ["third_party/dart/pubspec.lock"]); +} + +#[test] +fn conan_extensions_become_conan_sets() { + let module = "conan = use_extension(\"//build/conan:conan_deps_module_extension.bzl\", \"conan_extension\")\n"; + let read = |path: &str| match path { + "build/conanfile.py" => Ok(String::new()), + _ => Err("none".to_owned()), + }; + let registry = discover(module, &read) + .expect("discovers") + .expect("registry"); + let set = registry.find("conan").expect("set"); + assert_eq!(set.ecosystem, Ecosystem::Conan); + assert_eq!(set.manifests, ["build/conanfile.py"]); + assert_eq!(set.locks, ["build/conan.lock"]); +} + +#[test] +fn bundle_fetches_become_ruby_sets() { + let module = "ruby = use_extension(\"@rules_dx//ruby:extensions.bzl\", \"ruby\")\nruby.bundle_fetch(gemfile = \"//r:Gemfile\", gemfile_lock = \"//r:Gemfile.lock\")\n"; + let registry = found(module, &[]); + let set = registry.find("bundle").expect("bundle"); + assert_eq!(set.ecosystem, Ecosystem::Ruby); + assert_eq!(set.manifests, ["r/Gemfile"]); + assert_eq!(set.locks, ["r/Gemfile.lock"]); +} + +#[test] +fn paket_extensions_become_the_nuget_set() { + let module = "paket = use_extension(\"//third_party/dotnet:extension.bzl\", \"paket\")\n"; + let bzl = "load(\"//third_party/dotnet/deps:paket.main.bzl\", \"main\")\nload(\":framework.bzl\", \"framework\")\nload(\"//third_party/dotnet/deps:paket.docs.bzl\", \"docs\")\n"; + let registry = found(module, &[("third_party/dotnet/extension.bzl", bzl)]); + let set = registry.find("nuget").expect("nuget"); + assert_eq!(set.ecosystem, Ecosystem::Nuget); + assert_eq!(set.manifests, ["third_party/dotnet/paket.dependencies"]); + assert_eq!( + set.locks, + [ + "third_party/dotnet/paket.lock", + "third_party/dotnet/deps/paket.docs.bzl", + "third_party/dotnet/deps/paket.main.bzl", + ] + ); + let generated = + "main = use_extension(\"//x/deps:paket.main_extension.bzl\", \"main_extension\")\n"; + let registry = found(generated, &[]); + let set = registry.find("nuget").expect("nuget"); + assert_eq!(set.manifests, ["x/paket.dependencies"]); + assert_eq!(set.locks, ["x/paket.lock", "x/deps/paket.main.bzl"]); +} + +#[test] +fn included_module_files_are_followed() { + let module = "include(\"//tools:deps.MODULE.bazel\")\n"; + let included = format!( + "{NPM_EXT}npm.npm_translate_lock(name = \"tools\", pnpm_lock = \"//tools:pnpm-lock.yaml\")\n" + ); + let registry = found(module, &[("tools/deps.MODULE.bazel", &included)]); + assert_eq!(registry.names(), vec!["tools"]); +} + +#[test] +fn a_module_without_hubs_is_not_a_registry() { + assert_eq!(discovered("module(name = \"x\")\n", &[]), Ok(None)); +} + +#[test] +fn hub_problems_fail_closed() { + let duplicate = format!( + "{UV_EXT}uv.project(hub_name = \"pypi\", lock = \"//a/api:uv.lock\", pyproject = \"//a/api:pyproject.toml\")\nuv.project(hub_name = \"pypi\", lock = \"//b/api:uv.lock\", pyproject = \"//b/api:pyproject.toml\")\n" + ); + assert_eq!( + discovered(&duplicate, &[]), + Err(DependencySetsError::DuplicateName { + name: "pypi-api".to_owned() + }) + ); + let shared = format!( + "{NPM_EXT}npm.npm_translate_lock(name = \"a\", pnpm_lock = \"//:pnpm-lock.yaml\")\nnpm.npm_translate_lock(name = \"b\", pnpm_lock = \"//:pnpm-lock.yaml\")\n" + ); + assert!(matches!( + discovered(&shared, &[]), + Err(DependencySetsError::ConflictingOwnership { .. }) + )); + let computed = format!("{NPM_EXT}npm.npm_translate_lock(name = NAME)\n"); + assert!(matches!( + discovered(&computed, &[]), + Err(DependencySetsError::Module { .. }) + )); + assert!(matches!( + discovered( + "paket = use_extension(\"//d:extension.bzl\", \"paket\")\n", + &[] + ), + Err(DependencySetsError::Module { .. }) + )); +} + +#[test] +fn load_reads_the_workspace_module() { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-absent-"); + assert_eq!(load(&scratch.path().join("absent")).expect("absent"), None); + std::fs::write( + scratch.path().join(MODULE_BAZEL), + "go_deps = use_extension(\"@gazelle//:extensions.bzl\", \"go_deps\")\ngo_deps.from_file(go_mod = \"//:go.mod\")\n", + ) + .expect("module"); + let registry = load(scratch.path()).expect("loads").expect("registry"); + assert_eq!(registry.names(), vec!["go"]); +} + +#[test] +fn names_and_repositories_must_be_plain() { + for bad in ["", "a:b", "_a", "a b", "@a"] { + assert_eq!( + hub_set( + bad, + Ecosystem::Uv, + strings(&["a/pyproject.toml"]), + strings(&["a/uv.lock"]) + ), + Err(DependencySetsError::InvalidName { + name: bad.to_owned() + }), + "{bad}" + ); + } + assert_eq!( + hub_set( + "1crates", + Ecosystem::Cargo, + strings(&["a/Cargo.toml"]), + strings(&["a/Cargo.lock"]) + ), + Err(DependencySetsError::InvalidRepository { + name: "1crates".to_owned(), + repository: "1crates".to_owned(), + }) + ); + assert!( + hub_set( + "Crate_index.v2-x", + Ecosystem::Cargo, + strings(&["a/Cargo.toml"]), + strings(&["a/Cargo.lock"]) + ) + .is_ok() + ); + for bad in [ + "../up/pyproject.toml", + "a/../../pyproject.toml", + "/abs/pyproject.toml", + ] { + assert!( + matches!( + hub_set("a", Ecosystem::Uv, strings(&[bad]), strings(&["a/uv.lock"])), + Err(DependencySetsError::EscapingPath { .. }) + ), + "{bad}" + ); + } +} + +#[test] +fn two_same_ecosystem_sets_sort_by_name() { + let registry = two_uv(); + assert_eq!(registry.names(), vec!["frontend", "worker"]); + let frontend = registry.find("frontend").expect("frontend"); + assert_eq!(frontend.ecosystem, Ecosystem::Uv); + assert_eq!(frontend.manifests, vec!["apps/frontend/pyproject.toml"]); + assert_eq!(frontend.locks, vec!["apps/frontend/uv.lock"]); + assert_eq!(frontend.scopes, vec!["apps/frontend"]); + assert_eq!(ResolvedSet::of(frontend).dir, "apps/frontend"); +} + +#[test] +fn bare_selection_resolves_every_configured_set_full() { + let registry = two_uv(); + let resolved = resolve(®istry, &[]).expect("bare"); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].set.name, "frontend"); + assert_eq!(resolved[0].request, Selection::Full); + assert_eq!(resolved[1].set.name, "worker"); + assert_eq!(resolved[1].request, Selection::Full); +} +#[test] +fn names_scopes_and_packages_resolve() { + let registry = two_uv(); + let resolved = resolve(®istry, &strings(&["frontend"])).expect("name"); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].set.name, "frontend"); + let resolved = resolve(®istry, &strings(&["apps/frontend/app"])).expect("nested scope"); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].set.name, "frontend"); + let resolved = + resolve(®istry, &strings(&["//services/worker:target"])).expect("label scope"); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].set.name, "worker"); + let resolved = resolve(®istry, &strings(&["//..."])).expect("repo scope"); + assert_eq!(resolved.len(), 2); + let resolved = + resolve(®istry, &strings(&["frontend:anyio", "frontend:httpx"])).expect("packages"); + assert_eq!(resolved.len(), 1); + assert_eq!( + resolved[0].request, + Selection::Packages(vec!["anyio".to_owned(), "httpx".to_owned()]) + ); + let resolved = + resolve(®istry, &strings(&["frontend", "frontend:httpx"])).expect("full wins"); + assert_eq!(resolved[0].request, Selection::Full); +} + +#[test] +fn unknown_selectors_and_scopes_fail_closed() { + let registry = two_uv(); + assert!(matches!( + resolve(®istry, &strings(&["cargo"])), + Err(DependencySetsError::UnknownSelector { .. }) + )); + assert!(matches!( + resolve(®istry, &strings(&["docs/cli/README.md"])), + Err(DependencySetsError::NoOwningSet { .. }) + )); + assert!(matches!( + resolve(®istry, &strings(&["frontend:"])), + Err(DependencySetsError::InvalidPackage { .. }) + )); + assert!(matches!( + resolve(®istry, &strings(&["frontend:bad name"])), + Err(DependencySetsError::InvalidPackage { .. }) + )); +} + +#[test] +fn root_scope_owns_only_root_package_labels() { + let specs = |root: &'static [&'static str], lock: &'static [&'static str]| { + registry(&[ + ("root", Ecosystem::Npm, root, lock), + ( + "leaf", + Ecosystem::Uv, + &["apps/leaf/pyproject.toml"], + &["apps/leaf/uv.lock"], + ), + ]) + }; + let registry = specs(&["package.json"], &["pnpm-lock.yaml"]); + for target in ["//:node_modules", "//:", "."] { + assert_eq!(owning_sets(®istry, target), vec!["root"], "{target}"); + } + assert_eq!(owning_sets(®istry, "//foo:bar"), Vec::<&str>::new()); + assert_eq!(owning_sets(®istry, "//apps/leaf:t"), vec!["leaf"]); + assert_eq!(owning_sets(®istry, "//foo/..."), Vec::<&str>::new()); + assert_eq!(owning_sets(®istry, "//...").len(), 2); + assert_eq!(owning_sets(®istry, "//:MODULE.bazel").len(), 2); + let bare = specs(&["apps/root/package.json"], &["apps/root/pnpm-lock.yaml"]); + assert_eq!(owning_sets(&bare, "//:x"), Vec::<&str>::new()); +} + +#[test] +fn declared_manifests_and_locks_select_their_own_set() { + let registry = registry(&[ + ( + "cargo", + Ecosystem::Cargo, + &["rust/Cargo.toml"], + &["rust/Cargo.lock", "cargo-bazel-lock.json"], + ), + ( + "maven", + Ecosystem::Maven, + &["MODULE.bazel"], + &["third_party/jvm/maven_install.json"], + ), + ( + "npm", + Ecosystem::Npm, + &["web/package.json"], + &["web/pnpm-lock.yaml"], + ), + ("root", Ecosystem::Uv, &["pyproject.toml"], &["uv.lock"]), + ]); + assert_eq!( + owning_sets(®istry, "//:cargo-bazel-lock.json"), + vec!["cargo"] + ); + assert_eq!( + owning_sets(®istry, "third_party/jvm/maven_install.json"), + vec!["maven"] + ); + assert_eq!( + owning_sets(®istry, "//third_party/jvm:maven_install.json"), + vec!["maven"] + ); + assert_eq!(owning_sets(®istry, "//web:pnpm-lock.yaml"), vec!["npm"]); + assert_eq!(owning_sets(®istry, "//:other"), vec!["cargo", "root"]); +} + +#[test] +fn overlapping_scopes_resolve_to_every_owner() { + let registry = registry(&[ + ( + "mono", + Ecosystem::Uv, + &["apps/pyproject.toml"], + &["apps/uv.lock"], + ), + ( + "leaf", + Ecosystem::Uv, + &["apps/leaf/pyproject.toml"], + &["apps/leaf/uv.lock"], + ), + ]); + let resolved = resolve(®istry, &strings(&["apps/leaf/app"])).expect("overlap"); + assert_eq!(resolved.len(), 2); + let resolved = resolve(®istry, &strings(&["//apps/..."])).expect("recursive"); + assert_eq!(resolved.len(), 2); +} + +fn settings_workspace( + name: &str, + pyproject: &str, + config: Option<&str>, +) -> dx_test_scratch::TempDir { + let scratch = dx_test_scratch::scratch(&format!("dx-dependency-sets-settings-{name}-")); + let workspace = scratch.path().to_path_buf(); + std::fs::create_dir_all(workspace.join("apps/frontend")).expect("mkdir"); + std::fs::write(workspace.join("apps/frontend/pyproject.toml"), pyproject).expect("pyproject"); + if let Some(config) = config { + std::fs::write( + workspace.join(config), + "exclude-newer = \"2020-01-01T00:00:00Z\"\n", + ) + .expect("uv.toml"); + } + scratch +} + +#[test] +fn uv_settings_allow_project_metadata_only() { + let registry = two_uv(); + let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); + let metadata = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n\n[tool.uv]\npackage = false\ndev-dependencies = []\n\n[tool.uv.sources]\nlocal = { path = \"../local\" }\n\n[tool.ruff]\nline-length = 100\n"; + let scratch = settings_workspace("ok", metadata, None); + let workspace = scratch.path().to_path_buf(); + assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); + let resolver = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n\n[tool.uv]\nexclude-newer = \"2020-01-01T00:00:00Z\"\nindex-url = \"https://example.invalid/simple\"\n"; + let scratch = settings_workspace("tool", resolver, None); + let workspace = scratch.path().to_path_buf(); + let error = check_backend_settings(&workspace, &frontend).expect_err("resolver settings"); + assert_eq!( + error, + DependencySetsError::UnsupportedSettings { + name: "frontend".to_owned(), + detail: "apps/frontend/pyproject.toml sets [tool.uv] exclude-newer, index-url" + .to_owned(), + } + ); + let message = error.to_string(); + assert!(message.contains("\"frontend\""), "{message}"); + assert!(message.contains("does not apply"), "{message}"); +} + +#[test] +fn uv_config_files_in_the_set_or_its_parents_are_unsupported() { + let registry = two_uv(); + let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); + let plain = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n"; + for (config, shown) in [ + ("apps/frontend/uv.toml", "apps/frontend/uv.toml exists"), + ("apps/uv.toml", "apps/uv.toml exists"), + ("uv.toml", "uv.toml exists"), + ] { + let scratch = settings_workspace(&config.replace('/', "-"), plain, Some(config)); + let workspace = scratch.path().to_path_buf(); + assert_eq!( + check_backend_settings(&workspace, &frontend), + Err(DependencySetsError::UnsupportedSettings { + name: "frontend".to_owned(), + detail: shown.to_owned(), + }), + "{config}" + ); + } + let scratch = settings_workspace("missing", plain, None); + let workspace = scratch.path().to_path_buf(); + std::fs::remove_file(workspace.join("apps/frontend/pyproject.toml")).expect("rm"); + assert!(matches!( + check_backend_settings(&workspace, &frontend), + Err(DependencySetsError::Read { .. }) + )); +} + +#[test] +fn parent_uv_workspaces_are_unsupported() { + let registry = two_uv(); + let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); + let plain = "[project]\nname = \"frontend\"\nversion = \"0.1.0\"\n"; + for (parent, shown) in [ + ( + "apps/pyproject.toml", + "apps/pyproject.toml sets [tool.uv.workspace]", + ), + ("pyproject.toml", "pyproject.toml sets [tool.uv.workspace]"), + ] { + let scratch = settings_workspace(&parent.replace('/', "-"), plain, None); + let workspace = scratch.path().to_path_buf(); + std::fs::write( + workspace.join(parent), + "[tool.uv.workspace]\nmembers = [\"frontend\"]\n", + ) + .expect("parent pyproject"); + assert_eq!( + check_backend_settings(&workspace, &frontend), + Err(DependencySetsError::UnsupportedSettings { + name: "frontend".to_owned(), + detail: shown.to_owned(), + }), + "{parent}" + ); + } + let scratch = settings_workspace("plain-parent", plain, None); + let workspace = scratch.path().to_path_buf(); + std::fs::write(workspace.join("pyproject.toml"), plain).expect("parent pyproject"); + assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); +} + +#[test] +fn missing_files_fail_closed_with_paths() { + let registry = two_uv(); + let scratch = dx_test_scratch::scratch("dx-dependency-sets-missing-"); + let workspace = scratch.path().join("absent"); + let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); + assert!(matches!( + check_files(&workspace, &frontend, true), + Err(DependencySetsError::MissingFile { .. }) + )); +} + +#[cfg(unix)] +mod link_confinement { + use super::*; + + fn setup() -> (dx_test_scratch::TempDir, std::path::PathBuf, ResolvedSet) { + let outer = dx_test_scratch::scratch("dx-linkconf-"); + let workspace = outer.path().join("ws"); + std::fs::create_dir_all(&workspace).expect("ws"); + let registry = two_uv(); + let set = ResolvedSet::of(registry.find("frontend").expect("frontend")); + (outer, workspace, set) + } + + fn link_out(outer: &Path, workspace: &Path, rel: &str) -> std::path::PathBuf { + let outside = outer.join("outside.txt"); + std::fs::write(&outside, "x").expect("outside"); + let at = workspace.join(rel); + std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); + std::os::unix::fs::symlink(&outside, at).expect("link"); + outside + } + + fn is_outside(result: Result<(), DependencySetsError>) -> bool { + matches!(result, Err(DependencySetsError::OutsideWorkspace { .. })) + } + + #[test] + fn manifest_linking_outside_is_rejected() { + let (outer, workspace, set) = setup(); + link_out(outer.path(), &workspace, &set.manifests[0]); + assert!(is_outside(check_files(&workspace, &set, false))); + } + + #[test] + fn lock_linking_outside_is_rejected_with_and_without_need_lock() { + let (outer, workspace, set) = setup(); + for manifest in &set.manifests { + let at = workspace.join(manifest); + std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); + std::fs::write(at, "").expect("manifest"); + } + link_out(outer.path(), &workspace, &set.locks[0]); + assert!(is_outside(check_files(&workspace, &set, false))); + assert!(is_outside(check_files(&workspace, &set, true))); + } + + #[test] + fn dangling_lock_link_is_rejected_without_need_lock() { + let (outer, workspace, set) = setup(); + for manifest in &set.manifests { + let at = workspace.join(manifest); + std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); + std::fs::write(at, "").expect("manifest"); + } + let at = workspace.join(&set.locks[0]); + std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); + std::os::unix::fs::symlink(outer.path().join("nope"), at).expect("link"); + assert!(is_outside(check_files(&workspace, &set, false))); + } + + #[test] + fn lock_linking_inside_is_accepted() { + let (_outer, workspace, set) = setup(); + for manifest in &set.manifests { + let at = workspace.join(manifest); + std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); + std::fs::write(at, "").expect("manifest"); + } + let real = workspace.join("real.lock"); + std::fs::write(&real, "x").expect("real"); + let at = workspace.join(&set.locks[0]); + std::fs::create_dir_all(at.parent().expect("parent")).expect("dirs"); + std::os::unix::fs::symlink(&real, at).expect("link"); + assert_eq!(check_files(&workspace, &set, true), Ok(())); + } +} + +fn two_npm() -> Registry { + registry(&[ + ( + "frontend", + Ecosystem::Npm, + &["apps/web/package.json"], + &["apps/web/pnpm-lock.yaml"], + ), + ( + "worker", + Ecosystem::Npm, + &["services/worker/package.json"], + &["services/worker/pnpm-lock.yaml"], + ), + ]) +} + +#[test] +fn npm_sets_load_under_their_own_names() { + let registry = two_npm(); + assert_eq!(registry.names(), vec!["frontend", "worker"]); + let frontend = registry.find("frontend").expect("frontend"); + assert_eq!(frontend.ecosystem, Ecosystem::Npm); + assert_eq!(frontend.manifests, vec!["apps/web/package.json"]); + assert_eq!(frontend.locks, vec!["apps/web/pnpm-lock.yaml"]); + assert_eq!(ResolvedSet::of(frontend).dir, "apps/web"); + assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm)); + assert_eq!(Ecosystem::Npm.name(), "npm"); + assert_eq!(Ecosystem::Npm.supported_check(), "check"); + assert_eq!(Ecosystem::Npm.supported_update(), "update"); + assert_eq!(Ecosystem::Npm.supported_audit(), "audit"); + assert_eq!( + SUPPORTED_ECOSYSTEMS, + "uv, npm, bazel, ruby, powershell, cargo, maven, nuget, go, cpan, pub, conan, julia, hex, composer, opam, swiftpm, cabal, dub, luarocks, ansible, bicep" + ); +} + +#[test] +fn advisory_families_follow_the_ecosystem_never_the_set_name() { + assert_eq!(Ecosystem::Uv.advisory_family(), Some("pypi")); + assert_eq!(Ecosystem::Npm.advisory_family(), Some("npm")); + assert_eq!(Ecosystem::Bazel.advisory_family(), None); + assert_eq!(Ecosystem::Ruby.advisory_family(), Some("rubygems")); + assert_eq!(Ecosystem::PowerShell.advisory_family(), None); + let registry = two_npm(); + for set in ®istry.sets { + assert_eq!(set.ecosystem.advisory_family(), Some("npm"), "{}", set.name); + } +} + +#[test] +fn manifests_must_carry_the_ecosystem_file_name() { + for (ecosystem, manifest, want) in [ + ("uv", "a/package.json", "pyproject.toml"), + ("uv", "a/pyproject.toml.bak", "pyproject.toml"), + ("npm", "a/pyproject.toml", "package.json"), + ("npm", "a/package.json5", "package.json"), + ("npm", "package.yaml", "package.json"), + ("bazel", "a/package.json", "MODULE.bazel"), + ("ruby", "a/gems.rb", "Gemfile"), + ("ruby", "a/Gemfile.lock", "Gemfile"), + ("cargo", "a/cargo.toml", "Cargo.toml"), + ("cargo", "a/Cargo.lock", "Cargo.toml"), + ( + "powershell", + "a/requirements.psd1", + "PSGallery.requirements.psd1", + ), + ] { + let kind = Ecosystem::parse(ecosystem).expect("known"); + let error = hub_set("a", kind, strings(&[manifest]), strings(&["a/lock"])) + .expect_err("wrong manifest name"); + assert_eq!( + error, + DependencySetsError::InvalidManifest { + name: "a".to_owned(), + path: manifest.to_owned(), + want, + ecosystem: Ecosystem::parse(ecosystem).expect("known").name(), + }, + "{ecosystem} {manifest}" + ); + assert!(error.to_string().contains(want), "{error}"); + } + assert!( + hub_set( + "root", + Ecosystem::Npm, + strings(&["package.json"]), + strings(&["pnpm-lock.yaml"]) + ) + .is_ok() + ); +} + +#[test] +fn npm_packages_allow_one_scope_and_lowercase_names() { + let registry = two_npm(); + for package in [ + "react", + "@types/node", + "lodash.merge", + "a-b_c", + "@scope/x.y", + ] { + let resolved = resolve(®istry, &[format!("frontend:{package}")]) + .unwrap_or_else(|error| panic!("{package}: {error}")); + assert_eq!( + resolved, + vec![ResolvedTarget { + set: ResolvedSet::of(registry.find("frontend").expect("frontend")), + request: Selection::Packages(vec![package.to_owned()]), + }] + ); + } + for package in [ + "React", + "@types", + "@/node", + "@types/", + "@a/b/c", + ".hidden", + "_private", + "a b", + "a;rm", + "--registry=x", + "pkg@1.0.0", + ] { + assert!( + matches!( + resolve(®istry, &[format!("frontend:{package}")]), + Err(DependencySetsError::InvalidPackage { .. }) + ), + "{package} must be rejected" + ); + } +} + +fn npm_workspace(name: &str) -> dx_test_scratch::TempDir { + let scratch = dx_test_scratch::scratch(&format!("dx-dependency-sets-npm-{name}-")); + let workspace = scratch.path().to_path_buf(); + std::fs::create_dir_all(workspace.join("apps/web")).expect("set dir"); + std::fs::write(workspace.join("apps/web/package.json"), "{}\n").expect("manifest"); + scratch +} + +#[test] +fn npm_sets_need_their_own_pnpm_lock_and_no_parent_pnpm_workspace() { + let registry = two_npm(); + let frontend = ResolvedSet::of(registry.find("frontend").expect("frontend")); + let scratch = npm_workspace("plain"); + let workspace = scratch.path().to_path_buf(); + assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); + std::fs::write( + workspace.join("apps/web/pnpm-workspace.yaml"), + "packages: []\n", + ) + .expect("own workspace"); + assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); + for parent in ["apps/pnpm-workspace.yaml", "pnpm-workspace.yaml"] { + let scratch = npm_workspace(&parent.replace('/', "-")); + let workspace = scratch.path().to_path_buf(); + std::fs::write(workspace.join(parent), "packages: [\"apps/*\"]\n") + .expect("parent workspace"); + assert_eq!( + check_backend_settings(&workspace, &frontend), + Err(DependencySetsError::UnsupportedNpmLayout { + name: "frontend".to_owned(), + dir: "apps/web".to_owned(), + detail: format!("{parent} makes pnpm write the parent workspace lock"), + }), + "{parent}" + ); + } + let scratch = npm_workspace("root-only"); + let workspace = scratch.path().to_path_buf(); + std::fs::write( + workspace.join("pnpm-workspace.yaml"), + "packages:\n - \".\" # root\nallowBuilds: {}\n", + ) + .expect("root-only workspace"); + assert_eq!(check_backend_settings(&workspace, &frontend), Ok(())); + assert!(npm_ignore_workspace(&workspace, &frontend)); + std::fs::write( + workspace.join("apps/web/pnpm-workspace.yaml"), + "packages: []\n", + ) + .expect("own workspace"); + assert!(!npm_ignore_workspace(&workspace, &frontend)); + let scratch = npm_workspace("members"); + let workspace = scratch.path().to_path_buf(); + std::fs::write( + workspace.join("pnpm-workspace.yaml"), + "packages: [\"apps/*\"]\n", + ) + .expect("members workspace"); + assert!(!npm_ignore_workspace(&workspace, &frontend)); + let scratch = npm_workspace("other-lock"); + let workspace = scratch.path().to_path_buf(); + let mut other = frontend.clone(); + other.locks = vec!["apps/web/bun.lockb".to_owned()]; + assert_eq!( + check_backend_settings(&workspace, &other), + Err(DependencySetsError::UnsupportedNpmLayout { + name: "frontend".to_owned(), + dir: "apps/web".to_owned(), + detail: "locks must include apps/web/pnpm-lock.yaml, apps/web/yarn.lock, or apps/web/package-lock.json" + .to_owned(), + }) + ); + for (lock, backend) in [ + ("apps/web/yarn.lock", NpmBackend::Yarn), + ("apps/web/package-lock.json", NpmBackend::Npm), + ] { + other.locks = vec![lock.to_owned()]; + assert_eq!(check_backend_settings(&workspace, &other), Ok(()), "{lock}"); + assert_eq!(npm_backend(&other), Some(backend)); + } +} + +fn pinned() -> Registry { + registry(&[ + ( + "gems", + Ecosystem::Ruby, + &["apps/rb/Gemfile"], + &["apps/rb/Gemfile.lock"], + ), + ( + "modules", + Ecosystem::PowerShell, + &["tools/ps/PSGallery.requirements.psd1"], + &["tools/ps/PSGallery.lock.json"], + ), + ]) +} + +#[test] +fn cpan_module_names_allow_double_colons() { + for good in ["Moo", "Text::CSV", "Text::CSV_XS"] { + assert!(validate_package(Ecosystem::Cpan, good).is_ok(), "{good}"); + } + for bad in ["Text::", "::CSV", "Text:CSV", "Text::CSV-XS", ""] { + assert!(validate_package(Ecosystem::Cpan, bad).is_err(), "{bad}"); + } +} + +#[test] +fn ruby_and_powershell_sets_load_as_pinned_ecosystems() { + let registry = pinned(); + assert_eq!(registry.names(), vec!["gems", "modules"]); + let gems = registry.find("gems").expect("gems"); + assert_eq!(gems.ecosystem, Ecosystem::Ruby); + assert_eq!(ResolvedSet::of(gems).dir, "apps/rb"); + let modules = registry.find("modules").expect("modules"); + assert_eq!(modules.ecosystem, Ecosystem::PowerShell); + for (ecosystem, name, manifest) in [ + (Ecosystem::Ruby, "ruby", "Gemfile"), + ( + Ecosystem::PowerShell, + "powershell", + "PSGallery.requirements.psd1", + ), + ] { + assert_eq!(Ecosystem::parse(name), Some(ecosystem)); + assert_eq!(ecosystem.name(), name); + assert_eq!(ecosystem.manifest_name(), manifest); + assert_eq!(ecosystem.supported_check(), "pinned"); + assert_eq!(ecosystem.supported_update(), "pinned"); + assert_eq!(ecosystem.supported_audit(), "audit"); + assert_eq!( + ecosystem.supported_operations(), + "audit; check and update report pinned" + ); + } + assert_eq!(Ecosystem::Uv.supported_operations(), "check, update, audit"); + assert_eq!( + Ecosystem::Npm.supported_operations(), + "check, update, audit" + ); + assert_eq!(Ecosystem::parse("rubygems"), None); + assert_eq!(Ecosystem::parse("PowerShell"), None); +} + +#[test] +fn pinned_sets_must_declare_the_lock_beside_their_manifest() { + let registry = pinned(); + let scratch = dx_test_scratch::scratch("dx-dependency-sets-pinned-"); + let workspace = scratch.path().to_path_buf(); + for name in ["gems", "modules"] { + let set = ResolvedSet::of(registry.find(name).expect("set")); + assert_eq!(check_backend_settings(&workspace, &set), Ok(()), "{name}"); + } + for (ecosystem, manifest, lock, want) in [ + ( + "ruby", + "apps/rb/Gemfile", + "apps/rb/gems.locked", + "apps/rb/Gemfile.lock", + ), + ("ruby", "Gemfile", "other/Gemfile.lock", "Gemfile.lock"), + ( + "powershell", + "tools/ps/PSGallery.requirements.psd1", + "tools/ps/lock.json", + "tools/ps/PSGallery.lock.json", + ), + ] { + let kind = Ecosystem::parse(ecosystem).expect("known"); + let set = ResolvedSet::of( + &hub_set("a", kind, strings(&[manifest]), strings(&[lock])).expect("valid"), + ); + let error = check_backend_settings(&workspace, &set).expect_err("missing lock"); + assert_eq!( + error, + DependencySetsError::MissingPinnedLock { + name: "a".to_owned(), + path: want.to_owned(), + ecosystem: Ecosystem::parse(ecosystem).expect("known").name(), + }, + "{ecosystem} {lock}" + ); + assert!(error.to_string().contains(want), "{error}"); + } +} + +#[test] +fn pinned_packages_use_the_plain_charset() { + let registry = pinned(); + for (set, package) in [ + ("gems", "rspec-core"), + ("gems", "nokogiri"), + ("gems", "Ruby_Gem.2"), + ("modules", "Pester"), + ("modules", "Az.Accounts"), + ] { + let resolved = resolve(®istry, &[format!("{set}:{package}")]) + .unwrap_or_else(|error| panic!("{set}:{package}: {error}")); + assert_eq!( + resolved[0].request, + Selection::Packages(vec![package.to_owned()]) + ); + } + for (set, package, reason) in [ + ("gems", "a b", RUBY_PACKAGE_CHARSET), + ("gems", "rails@7", RUBY_PACKAGE_CHARSET), + ("gems", "a;rm", RUBY_PACKAGE_CHARSET), + ("modules", "Az/Accounts", POWERSHELL_PACKAGE_CHARSET), + ("modules", "Az Accounts", POWERSHELL_PACKAGE_CHARSET), + ] { + assert_eq!( + resolve(®istry, &[format!("{set}:{package}")]), + Err(DependencySetsError::InvalidPackage { + set: set.to_owned(), + package: package.to_owned(), + reason, + }), + "{set}:{package}" + ); + } +} + +fn cargo_sets() -> Registry { + registry(&[ + ( + "crates", + Ecosystem::Cargo, + &["apps/rs/Cargo.toml"], + &["apps/rs/Cargo.lock", "apps/rs/cargo-bazel-lock.json"], + ), + ( + "tool_crates", + Ecosystem::Cargo, + &["tools/rs/Cargo.toml"], + &["tools/rs/Cargo.lock"], + ), + ]) +} + +const CARGO_EXT: &str = + "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\n"; + +const CARGO_HUBS: &str = "crate = use_extension(\"@rules_rust//crate_universe:extensions.bzl\", \"crate\")\ncrate.from_cargo(name = \"crates\", cargo_lockfile = \"//apps/rs:Cargo.lock\", lockfile = \"//apps/rs:cargo-bazel-lock.json\", manifests = [\"//apps/rs:Cargo.toml\"])\ncrate.from_cargo(name = \"tool_crates\", cargo_lockfile = \"//tools/rs:Cargo.lock\", manifests = [\"//tools/rs:Cargo.toml\"])\nuse_repo(crate, \"crates\", \"tool_crates\")\n"; + +fn cargo_hub_detail(module: &str, set: &ResolvedSet) -> String { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-cargo-hub-"); + std::fs::write(scratch.path().join(MODULE_BAZEL), module).expect("module"); + match check_backend_settings(scratch.path(), set) { + Err(DependencySetsError::UnsupportedCargoHub { detail, .. }) => detail, + other => panic!("expected UnsupportedCargoHub, got {other:?}"), + } +} + +#[test] +fn cargo_hubs_resolve_by_their_visible_name_and_refuse_everything_else() { + let registry = cargo_sets(); + let crates = ResolvedSet::of(registry.find("crates").expect("crates")); + let scratch = dx_test_scratch::scratch("dx-dependency-sets-cargo-visible-"); + let hub = |import: &str| { + format!( + "{CARGO_EXT}crate.from_cargo(name = \"crates\", cargo_lockfile = \"//apps/rs:Cargo.lock\", lockfile = \"//apps/rs:cargo-bazel-lock.json\", manifests = [\"//apps/rs:Cargo.toml\"])\n{import}" + ) + }; + for (import, visible) in [ + ("use_repo(crate, \"crates\")\n", "crates"), + ("use_repo(crate, deps = \"crates\")\n", "deps"), + ] { + std::fs::write(scratch.path().join(MODULE_BAZEL), hub(import)).expect("module"); + assert_eq!(check_backend_settings(scratch.path(), &crates), Ok(())); + assert_eq!( + cargo_fetch_repository(scratch.path(), &crates).as_deref(), + Some(visible) + ); + } + assert!(cargo_hub_detail(CARGO_EXT, &crates).contains("no crate.from_cargo")); + assert!(cargo_hub_detail(&hub(""), &crates).contains("use_repo")); + let two = hub("use_repo(crate, \"crates\")\n").replace( + "use_repo", + "crate.from_cargo(name = \"crates\", cargo_lockfile = \"//apps/rs:Cargo.lock\", manifests = [\"//apps/rs:Cargo.toml\"])\nuse_repo", + ); + assert!(cargo_hub_detail(&two, &crates).contains("2 crate.from_cargo")); + let mismatch = + hub("use_repo(crate, \"crates\")\n").replace("//apps/rs:Cargo.lock", "//other:Cargo.lock"); + assert!(cargo_hub_detail(&mismatch, &crates).contains("cargo_lockfile")); + let unlisted = + hub("use_repo(crate, \"crates\")\n").replace("cargo-bazel-lock.json", "other.json"); + assert!(cargo_hub_detail(&unlisted, &crates).contains("not listed in the set's locks")); + let mut stray = crates.clone(); + stray.manifests.push("stray/Cargo.toml".to_owned()); + assert!( + cargo_hub_detail(&hub("use_repo(crate, \"crates\")\n"), &stray) + .contains("stray/Cargo.toml") + ); +} + +#[test] +fn cargo_sets_may_span_directories() { + let set = hub_set( + "a", + Ecosystem::Cargo, + strings(&["a/Cargo.toml", "b/Cargo.toml"]), + strings(&["a/Cargo.lock"]), + ) + .expect("valid"); + assert_eq!(set.manifests.len(), 2); + assert_eq!(set.scopes, ["a", "b"]); +} + +#[test] +fn cargo_sets_load_with_their_repository() { + let registry = cargo_sets(); + assert_eq!(registry.names(), vec!["crates", "tool_crates"]); + let crates = registry.find("crates").expect("crates"); + assert_eq!(crates.ecosystem, Ecosystem::Cargo); + assert_eq!(crates.repository.as_deref(), Some("crates")); + let resolved = ResolvedSet::of(crates); + assert_eq!(resolved.dir, "apps/rs"); + assert_eq!(resolved.repository.as_deref(), Some("crates")); + assert_eq!(Ecosystem::parse("cargo"), Some(Ecosystem::Cargo)); + assert_eq!(Ecosystem::Cargo.name(), "cargo"); + assert_eq!(Ecosystem::Cargo.manifest_name(), "Cargo.toml"); + assert_eq!(Ecosystem::Cargo.supported_check(), "check"); + assert_eq!(Ecosystem::Cargo.supported_update(), "update"); + assert_eq!( + Ecosystem::Cargo.supported_operations(), + "check, update, audit" + ); + assert_eq!(Ecosystem::Cargo.advisory_family(), Some("cargo")); + assert!(Ecosystem::Cargo.uses_repository()); + for other in [ + Ecosystem::Uv, + Ecosystem::Npm, + Ecosystem::Bazel, + Ecosystem::Ruby, + Ecosystem::PowerShell, + ] { + assert!(!other.uses_repository(), "{}", other.name()); + } + let uv = two_uv(); + assert_eq!(uv.find("frontend").expect("frontend").repository, None); +} + +#[test] +fn cargo_sets_must_declare_cargo_lock_beside_the_manifest() { + let registry = cargo_sets(); + let scratch = dx_test_scratch::scratch("dx-dependency-sets-cargo-"); + let workspace = scratch.path().to_path_buf(); + std::fs::write(workspace.join(MODULE_BAZEL), CARGO_HUBS).expect("module"); + for name in ["crates", "tool_crates"] { + let set = ResolvedSet::of(registry.find(name).expect("set")); + assert_eq!(check_backend_settings(&workspace, &set), Ok(()), "{name}"); + } + let set = ResolvedSet::of( + &hub_set( + "a", + Ecosystem::Cargo, + strings(&["a/Cargo.toml"]), + strings(&["a/cargo-bazel-lock.json"]), + ) + .expect("valid"), + ); + assert_eq!( + check_backend_settings(&workspace, &set), + Err(DependencySetsError::MissingPinnedLock { + name: "a".to_owned(), + path: "a/Cargo.lock".to_owned(), + ecosystem: "cargo", + }) + ); +} + +#[test] +fn cargo_packages_use_the_crate_charset() { + let registry = cargo_sets(); + for package in ["serde", "serde_json", "tokio-util", "Inflector"] { + let resolved = resolve(®istry, &[format!("crates:{package}")]) + .unwrap_or_else(|error| panic!("{package}: {error}")); + assert_eq!( + resolved[0].request, + Selection::Packages(vec![package.to_owned()]) + ); + } + for package in ["serde@1.0.0", "a.b", "a b", "a;rm", "--locked", "_a", "a/b"] { + assert_eq!( + resolve(®istry, &[format!("crates:{package}")]), + Err(DependencySetsError::InvalidPackage { + set: "crates".to_owned(), + package: package.to_owned(), + reason: CARGO_PACKAGE_CHARSET, + }), + "{package}" + ); + } +} +const MAVEN_MODULE_TEXT: &str = "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"tool_maven\", lock_file = \"//tools:tool_install.json\")\nuse_repo(maven, \"maven\", \"tool_maven\")\n"; + +fn maven_sets() -> Registry { + registry(&[ + ( + "maven", + Ecosystem::Maven, + &["MODULE.bazel"], + &["third_party/maven_install.json"], + ), + ( + "tool_maven", + Ecosystem::Maven, + &["MODULE.bazel"], + &["tools/tool_install.json"], + ), + ( + "root", + Ecosystem::Bazel, + &["MODULE.bazel"], + &["MODULE.bazel.lock"], + ), + ]) +} + +const SIGNED_LOCK: &str = "{\"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY\":\"x\",\"__INPUT_ARTIFACTS_HASH\":1,\"__RESOLVED_ARTIFACTS_HASH\":2}"; + +fn maven_workspace(module: &str, lock: &str) -> (dx_test_scratch::TempDir, ResolvedSet) { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-maven-"); + let root = scratch.path(); + std::fs::write(root.join("MODULE.bazel"), module).expect("module"); + std::fs::create_dir_all(root.join("third_party")).expect("dir"); + std::fs::write(root.join("third_party/maven_install.json"), lock).expect("lock"); + let registry = maven_sets(); + let set = ResolvedSet::of(registry.find("maven").expect("maven")); + (scratch, set) +} + +fn hub_detail(result: Result<(), DependencySetsError>) -> String { + match result { + Err(DependencySetsError::UnsupportedMavenHub { detail, .. }) => detail, + other => panic!("expected UnsupportedMavenHub, got {other:?}"), + } +} + +#[test] +fn maven_sets_share_the_root_module_but_own_their_locks() { + let registry = maven_sets(); + assert_eq!(registry.names(), vec!["maven", "root", "tool_maven"]); + let jvm = registry.find("maven").expect("maven"); + assert_eq!(jvm.ecosystem, Ecosystem::Maven); + assert_eq!(jvm.repository.as_deref(), Some("maven")); + assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven)); + assert_eq!(Ecosystem::Maven.manifest_name(), "MODULE.bazel"); + assert_eq!( + Ecosystem::Maven.supported_operations(), + "check, update, audit" + ); + assert_eq!(Ecosystem::Maven.advisory_family(), Some("maven")); + assert!(Ecosystem::Maven.uses_repository()); + assert!(matches!( + try_registry(&[ + ( + "a", + Ecosystem::Maven, + &["MODULE.bazel"], + &["third_party/maven_install.json"], + ), + ( + "b", + Ecosystem::Maven, + &["MODULE.bazel"], + &["third_party/maven_install.json"], + ), + ]), + Err(DependencySetsError::ConflictingOwnership { .. }) + )); + let two_uv = two_uv(); + assert!(two_uv.find("frontend").is_some()); +} + +#[test] +fn maven_packages_are_group_and_artifact() { + let registry = maven_sets(); + assert!(resolve(®istry, &["maven:com.google.guava:guava".to_owned()]).is_ok()); + for package in ["guava", "a:", ":b", "a:b:1.0", "a b:c", "a;x:b"] { + assert_eq!( + resolve(®istry, &[format!("maven:{package}")]), + Err(DependencySetsError::InvalidPackage { + set: "maven".to_owned(), + package: package.to_owned(), + reason: MAVEN_PACKAGE_CHARSET, + }), + "{package}" + ); + } +} + +#[test] +fn maven_hub_must_exist_in_the_root_module() { + let (scratch, set) = maven_workspace(MAVEN_MODULE_TEXT, ""); + let root = scratch.path(); + assert_eq!(check_backend_settings(root, &set), Ok(())); + let cases = [ + ( + "bazel_dep(name = \"x\", version = \"1\")\n", + "no maven.install", + ), + ( + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\n", + "use_repo", + ), + ( + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nuse_repo(maven, m2 = \"maven\")\n", + "use_repo", + ), + ( + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\")\nuse_repo(maven, \"maven\")\n", + "literal lock_file", + ), + ( + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//other:lock.json\")\nuse_repo(maven, \"maven\")\n", + "not listed", + ), + ( + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = L)\nuse_repo(maven, \"maven\")\n", + "literal name and lock_file", + ), + ( + "maven = use_extension(\"@rules_jvm_external//:extensions.bzl\", \"maven\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nmaven.install(name = \"maven\", lock_file = \"//third_party:maven_install.json\")\nuse_repo(maven, \"maven\")\n", + "2 maven.install", + ), + ]; + for (module, want) in cases { + std::fs::write(root.join("MODULE.bazel"), module).expect("module"); + let detail = hub_detail(check_backend_settings(root, &set)); + assert!(detail.contains(want), "{detail} should contain {want}"); + } + std::fs::write(root.join("MODULE.bazel"), MAVEN_MODULE_TEXT).expect("module"); + std::fs::remove_file(root.join("third_party/maven_install.json")).expect("rm"); + assert!(hub_detail(check_backend_settings(root, &set)).contains("does not exist")); +} + +#[test] +fn maven_check_needs_a_signed_lock_and_fail_if_repin_required() { + let (scratch, set) = maven_workspace(MAVEN_MODULE_TEXT, SIGNED_LOCK); + let root = scratch.path(); + assert_eq!(check_lock_ready(root, &set), Ok(())); + let lock = root.join("third_party/maven_install.json"); + for bad in [ + "", + "{}", + "not json", + "[1]", + "{\"__INPUT_ARTIFACTS_HASH\":1}", + "{\"dependency_tree\":{}}", + ] { + std::fs::write(&lock, bad).expect("lock"); + assert!( + hub_detail(check_lock_ready(root, &set)).contains("signatures"), + "{bad}" + ); + } + std::fs::write(&lock, SIGNED_LOCK).expect("lock"); + for policy in ["False", "FLAG"] { + let module = MAVEN_MODULE_TEXT.replacen( + "maven_install.json\")", + &format!("maven_install.json\", fail_if_repin_required = {policy})"), + 1, + ); + std::fs::write(root.join("MODULE.bazel"), module).expect("module"); + assert!(hub_detail(check_lock_ready(root, &set)).contains("fail_if_repin_required")); + } + let module = MAVEN_MODULE_TEXT.replacen( + "maven_install.json\")", + "maven_install.json\", fail_if_repin_required = True)", + 1, + ); + std::fs::write(root.join("MODULE.bazel"), module).expect("module"); + assert_eq!(check_lock_ready(root, &set), Ok(())); + let registry = two_uv(); + let uv = ResolvedSet::of(registry.find("frontend").expect("frontend")); + assert_eq!(check_lock_ready(root, &uv), Ok(())); +} + +const NUGET_LOCKS: [&str; 2] = [ + "third_party/dotnet/paket.lock", + "third_party/dotnet/deps/paket.main.bzl", +]; + +fn nuget_set(locks: &[&str]) -> DependencySet { + hub_set( + "dotnet", + Ecosystem::Nuget, + strings(&["third_party/dotnet/paket.dependencies"]), + strings(locks), + ) + .expect("valid") +} + +const NUGET_MODULE: &str = "module(name = \"consumer\")\n"; + +fn nuget_workspace( + locks: &[&str], + module: &str, + dependencies: &str, +) -> (dx_test_scratch::TempDir, ResolvedSet) { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-nuget-"); + let root = scratch.path(); + std::fs::write(root.join("MODULE.bazel"), module).expect("module"); + std::fs::create_dir_all(root.join("third_party/dotnet/deps")).expect("dir"); + std::fs::write( + root.join("third_party/dotnet/paket.dependencies"), + dependencies, + ) + .expect("dependencies"); + (scratch, ResolvedSet::of(&nuget_set(locks))) +} + +fn nuget_detail(result: Result<(), DependencySetsError>) -> String { + match result { + Err(DependencySetsError::UnsupportedNugetLayout { detail, .. }) => detail, + other => panic!("expected UnsupportedNugetLayout, got {other:?}"), + } +} + +#[test] +fn nuget_sets_load_with_paket_manifests() { + let set = nuget_set(&NUGET_LOCKS); + let registry = Registry { + sets: vec![set.clone()], + }; + let dotnet = &set; + assert_eq!(dotnet.ecosystem, Ecosystem::Nuget); + assert_eq!(Ecosystem::parse("nuget"), Some(Ecosystem::Nuget)); + assert_eq!(Ecosystem::Nuget.manifest_name(), "paket.dependencies"); + assert_eq!( + Ecosystem::Nuget.supported_operations(), + "check, update, audit" + ); + assert_eq!(Ecosystem::Nuget.advisory_family(), Some("nuget")); + assert!(!Ecosystem::Nuget.uses_repository()); + assert!(matches!( + hub_set( + "dotnet", + Ecosystem::Nuget, + strings(&["third_party/dotnet/packages.config"]), + strings(&NUGET_LOCKS), + ), + Err(DependencySetsError::InvalidManifest { .. }) + )); + assert!(resolve(®istry, &["dotnet:Newtonsoft.Json".to_owned()]).is_ok()); + for package in ["a b", "a;b", "a/b"] { + assert_eq!( + resolve(®istry, &[format!("dotnet:{package}")]), + Err(DependencySetsError::InvalidPackage { + set: "dotnet".to_owned(), + package: package.to_owned(), + reason: NUGET_PACKAGE_CHARSET, + }), + "{package}" + ); + } +} + +#[test] +fn nuget_layout_accepts_one_folder_of_groups() { + let (scratch, set) = nuget_workspace( + &NUGET_LOCKS, + NUGET_MODULE, + "source https://api.nuget.org/v3/index.json\nnuget Newtonsoft.Json\n", + ); + assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); +} + +#[test] +fn nuget_layout_refuses_what_staging_cannot_reproduce() { + let dependencies = "source https://api.nuget.org/v3/index.json\n"; + let main = "third_party/dotnet/deps/paket.main.bzl"; + let lock = "third_party/dotnet/paket.lock"; + let notes: &[&str] = &[lock, "third_party/dotnet/deps/notes.txt"]; + let other: &[&str] = &[lock, main, "third_party/dotnet/other/paket.docs.bzl"]; + let extension: &[&str] = &[ + lock, + main, + "third_party/dotnet/deps/paket.main_extension.bzl", + ]; + let cases: [(&[&str], &str, &str, &str); 4] = [ + (notes, NUGET_MODULE, dependencies, "neither paket.lock"), + (other, NUGET_MODULE, dependencies, "share one folder"), + ( + extension, + NUGET_MODULE, + dependencies, + "paket2bazel extension", + ), + ( + &NUGET_LOCKS, + NUGET_MODULE, + "source ../local-feed\n", + "not http(s)", + ), + ]; + let (scratch, set) = nuget_workspace(&[main], NUGET_MODULE, dependencies); + assert!(matches!( + check_backend_settings(scratch.path(), &set), + Err(DependencySetsError::MissingPinnedLock { .. }) + )); + for (locks, module, dependencies, want) in cases { + let (scratch, set) = nuget_workspace(locks, module, dependencies); + let detail = nuget_detail(check_backend_settings(scratch.path(), &set)); + assert!(detail.contains(want), "{want}: {detail}"); + } +} + +const GO_SUM: [&str; 1] = ["third_party/go/go.sum"]; + +fn go_set(manifest: &str, locks: &[&str]) -> Result { + hub_set("go", Ecosystem::Go, strings(&[manifest]), strings(locks)) +} + +const GO_MODULE: &str = "bazel_dep(name = \"rules_go\", version = \"0.50.1\")\n"; + +const GO_MOD_TEXT: &str = + "module example.com/m\n\ngo 1.22\n\nrequire github.com/google/go-cmp v0.7.0\n"; + +fn go_workspace( + locks: &[&str], + module: &str, + go_mod: &str, +) -> (dx_test_scratch::TempDir, ResolvedSet) { + let scratch = dx_test_scratch::scratch("dx-dependency-sets-go-"); + let root = scratch.path(); + std::fs::write(root.join("MODULE.bazel"), module).expect("module"); + std::fs::create_dir_all(root.join("third_party/go")).expect("dir"); + std::fs::write(root.join("third_party/go/go.mod"), go_mod).expect("go.mod"); + let set = go_set("third_party/go/go.mod", locks).expect("valid"); + (scratch, ResolvedSet::of(&set)) +} + +fn go_detail(result: Result<(), DependencySetsError>) -> String { + match result { + Err(DependencySetsError::UnsupportedGoLayout { detail, .. }) => detail, + other => panic!("expected UnsupportedGoLayout, got {other:?}"), + } +} + +#[test] +fn go_sets_load_with_go_mod_manifests() { + let registry = Registry { + sets: vec![go_set("third_party/go/go.mod", &GO_SUM).expect("valid")], + }; + let set = registry.find("go").expect("go"); + assert_eq!(set.ecosystem, Ecosystem::Go); + assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go)); + assert_eq!(Ecosystem::Go.manifest_name(), "go.mod"); + assert_eq!(Ecosystem::Go.supported_operations(), "check, update, audit"); + assert_eq!(Ecosystem::Go.advisory_family(), Some("go")); + assert!(!Ecosystem::Go.uses_repository()); + assert!(matches!( + go_set("third_party/go/go.work", &GO_SUM), + Err(DependencySetsError::InvalidManifest { .. }) + )); + assert!(resolve(®istry, &["go:github.com/google/go-cmp".to_owned()]).is_ok()); + assert!(matches!( + resolve(®istry, &["go:a b".to_owned()]), + Err(DependencySetsError::InvalidPackage { .. }) + )); +} + +#[test] +fn go_layout_accepts_one_go_mod_with_go_sum() { + let (scratch, set) = go_workspace(&GO_SUM, GO_MODULE, GO_MOD_TEXT); + assert_eq!(check_backend_settings(scratch.path(), &set), Ok(())); +} + +#[test] +fn go_layout_refuses_what_staging_cannot_reproduce() { + let (scratch, set) = go_workspace(&["third_party/go/go.work.sum"], GO_MODULE, GO_MOD_TEXT); + assert!(matches!( + check_backend_settings(scratch.path(), &set), + Err(DependencySetsError::MissingPinnedLock { .. }) + )); + let extra: &[&str] = &["third_party/go/go.sum", "third_party/go/go.work.sum"]; + let replaced = format!("{GO_MOD_TEXT}\nreplace example.com/x => ../x\n"); + let renamed = + "bazel_dep(name = \"rules_go\", version = \"0.50.1\", repo_name = \"io_bazel_rules_go\")\n"; + let with_env = + format!("{GO_MODULE}\ngo_deps.config(go_env = {{\"GOPRIVATE\": \"corp.example.com\"}})\n"); + let cases: [(&[&str], &str, &str, &str); 6] = [ + (&GO_SUM, with_env.as_str(), GO_MOD_TEXT, "go_env"), + (extra, GO_MODULE, GO_MOD_TEXT, "exactly"), + (&GO_SUM, GO_MODULE, replaced.as_str(), "local path"), + (&GO_SUM, GO_MODULE, "not a go.mod {", "cannot parse"), + (&GO_SUM, "", GO_MOD_TEXT, "bazel_dep(name = \"rules_go\")"), + (&GO_SUM, renamed, GO_MOD_TEXT, "repo_name"), + ]; + for (locks, module, go_mod, want) in cases { + let (scratch, set) = go_workspace(locks, module, go_mod); + let detail = go_detail(check_backend_settings(scratch.path(), &set)); + assert!(detail.contains(want), "{want}: {detail}"); + } +} diff --git a/cli/plan/BUILD.bazel b/cli/plan/BUILD.bazel index 75d25702c8..743d26f1ab 100644 --- a/cli/plan/BUILD.bazel +++ b/cli/plan/BUILD.bazel @@ -163,6 +163,7 @@ rust_test( "src/resolve/test_map.rs", "src/resolve/types.rs", "src/slots.rs", + "src/slots_tests.rs", "src/slots/pressure.rs", ], aliases = aliases( diff --git a/cli/plan/src/slots.rs b/cli/plan/src/slots.rs index 59c23412cf..5654812512 100644 --- a/cli/plan/src/slots.rs +++ b/cli/plan/src/slots.rs @@ -1977,2932 +1977,5 @@ fn slot_settles_free(dir: &Path, count: usize) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use std::process::{Command as Process, Stdio}; - - fn hold( - command: Command, - workspace: &Path, - env_get: &dyn Fn(&str) -> Option, - err: &mut dyn Write, - json: bool, - events: &mut dyn Write, - ) -> Result, Stop> { - Gate::from_env(workspace, env_get, json, &[], BuildSlots::Auto) - .map(|gate| gate.with_pressure(Arc::new(Fixed(Some(false))))) - .map_or(Ok(None), |gate| gate.hold(command, err, events)) - } - - const ALONE_ENV: &str = "DX_SLOT_TEST_ALONE"; - - /// Runs `body` in a child test process so no other test touches the process share. - fn alone(test: &str, body: impl FnOnce()) { - if std::env::var_os(ALONE_ENV).is_some() { - body(); - return; - } - let status = Process::new(std::env::current_exe().expect("exe")) - .arg(format!("slots::tests::{test}")) - .arg("--exact") - .env(ALONE_ENV, "1") - .stdout(Stdio::null()) - .status() - .expect("spawn"); - assert!(status.success(), "{test} failed in its own process"); - } - - const HOLD_ENV: &str = "DX_SLOT_TEST_HOLD"; - - #[test] - fn slot_holder_helper() { - let Ok(dir) = std::env::var(HOLD_ENV) else { - return; - }; - let _slot = acquire(Path::new(&dir), 1, "helper-tree", &mut |_| {}).expect("slot"); - std::fs::write(Path::new(&dir).join("held"), b"1").expect("marker"); - std::thread::sleep(Duration::from_secs(120)); - } - - fn wait_for(path: &Path, what: &str) { - let start = std::time::Instant::now(); - while !path.exists() { - assert!(start.elapsed() < Duration::from_secs(30), "{what}"); - std::thread::sleep(Duration::from_millis(25)); - } - } - - fn spawn_holder(dir: &Path) -> std::process::Child { - let child = Process::new(std::env::current_exe().expect("exe")) - .arg("slots::tests::slot_holder_helper") - .arg("--exact") - .env(HOLD_ENV, dir) - .stdout(Stdio::null()) - .spawn() - .expect("spawn"); - wait_for(&dir.join("held"), "holder never took the slot"); - child - } - - #[test] - fn count_follows_memory_with_bounds() { - assert_eq!(slot_count(0, 0), 1); - assert_eq!(slot_count(4 << 30, 0), 1); - assert_eq!(slot_count(8 << 30, 0), 2); - assert_eq!(slot_count(16 << 30, 0), 5); - assert_eq!(slot_count(16 << 30, 6 << 30), 3); - assert_eq!(slot_count(24 << 30, 0), 8); - assert_eq!(slot_count(u64::MAX, 0), MAX_SLOTS); - assert_eq!(slot_count(12 << 30, 6 << 30), 2); - assert_eq!(slot_count(20 << 30, 6 << 30), 4); - assert_eq!(slot_count(24 << 30, 6 << 30), 6); - } - - #[test] - fn remote_count_follows_memory_with_bounds() { - assert_eq!(remote_slot_count(0, 0), 1); - assert_eq!(remote_slot_count(19 << 30, 0), 12); - assert_eq!(remote_slot_count(19 << 30, 6 << 30), 8); - assert_eq!(remote_slot_count(u64::MAX, 0), MAX_SLOTS); - let extras = |memory, reserved| { - remote_slot_count(memory, reserved).saturating_sub(slot_count(memory, reserved)) - }; - assert_eq!(extras(19 << 30, 0), 6); - assert_eq!(extras(19 << 30, 6 << 30), 4); - assert_eq!(extras(2 << 30, 0), 0); - } - - #[test] - fn extras_sit_above_the_pool() { - let rules_dx = Gate::new(PathBuf::from("/slots"), 4, "r".to_owned(), false) - .reserving(6) - .extra(4, true) - .layout(); - assert_eq!( - ( - rules_dx.count, - rules_dx.reserve.clone(), - rules_dx.extras.clone() - ), - (4, 4..6, 6..10) - ); - assert_eq!( - rules_dx.counted().collect::>(), - [0, 1, 2, 3, 6, 7, 8, 9] - ); - let consumer = Gate::new(PathBuf::from("/slots"), 6, "c".to_owned(), false) - .reserving(6) - .extra(6, false) - .layout(); - assert_eq!( - (consumer.count, consumer.reserve, consumer.extras), - (6, 6..6, 6..12) - ); - } - - #[derive(Debug)] - struct Fixed(Option); - - impl Pressure for Fixed { - fn high(&self) -> Option { - self.0 - } - } - - fn remote_gate(dir: &Path, remote: bool, high: Option) -> Gate { - Gate::new(dir.to_path_buf(), 1, "remote".to_owned(), false) - .extra(2, remote) - .with_pressure(Arc::new(Fixed(high))) - } - - fn free(dir: &Path, index: usize) -> bool { - matches!(lock_slot(&slot_path(dir, index), false), Ok(Some(_))) - } - - #[test] - fn a_slot_without_a_holder_file_still_counts_as_held() { - let scratch = dx_test_scratch::scratch("dx-slots-no-holder-"); - let dir = scratch.path().join("slots"); - let slot = try_take(&dir, 2, "busy").expect("try").expect("slot"); - std::fs::remove_file(holder_path(&dir, slot.index)).expect("remove holder"); - assert_eq!(held(&dir, 0..2, usize::MAX), 2); - } - - #[test] - fn a_local_gate_never_takes_an_extra() { - let scratch = dx_test_scratch::scratch("dx-slots-local-extra-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); - let gate = remote_gate(&dir, false, Some(false)); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - std::thread::sleep(POLL * 2); - assert!(free(&dir, 1) && free(&dir, 2)); - drop(fill); - assert_eq!(waiter.join().expect("join").0.index, 0); - } - - #[test] - fn a_remote_gate_takes_a_base_slot_then_an_extra_when_calm() { - let scratch = dx_test_scratch::scratch("dx-slots-remote-extra-"); - let dir = scratch.path().join("slots"); - let gate = remote_gate(&dir, true, Some(false)); - let (base, base_err, _) = take_gate(&gate); - let (extra, extra_err, _) = take_gate(&gate); - assert_eq!((base.index, extra.index), (0, 1)); - assert_eq!((base_err.as_str(), extra_err.as_str()), ("", "")); - assert!(extra_mark(&dir).is_file()); - } - - #[test] - fn a_remote_gate_waits_under_pressure_or_without_a_signal() { - for high in [Some(true), None] { - let scratch = dx_test_scratch::scratch("dx-slots-remote-pressure-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); - let gate = remote_gate(&dir, true, high); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - std::thread::sleep(POLL * 2); - assert!(free(&dir, 1) && free(&dir, 2)); - drop(fill); - let (slot, err, _) = waiter.join().expect("join"); - assert_eq!(slot.index, 0); - let want = match high { - Some(true) => wait_message("fill; extra slots paused under memory pressure"), - _ => wait_message("fill"), - }; - assert_eq!(err, format!("dx: {want}\n")); - } - } - - #[test] - fn a_remote_waiter_does_not_jump_the_line_through_a_free_extra() { - let scratch = dx_test_scratch::scratch("dx-slots-remote-line-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); - let queued = enqueue(&dir, "").expect("queued"); - let gate = remote_gate(&dir, true, Some(false)); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - std::thread::sleep(POLL * 2); - assert!(!waiter.is_finished(), "a remote waiter must keep its place"); - assert!(free(&dir, 1) && free(&dir, 2)); - drop(queued); - let (slot, err, _) = waiter.join().expect("join"); - assert_eq!(slot.index, 1); - assert_eq!( - err, - format!("dx: {}\n", wait_message("fill, 1 ahead in line")) - ); - drop(fill); - } - - #[test] - fn held_extras_under_pressure_add_no_paused_suffix() { - let scratch = dx_test_scratch::scratch("dx-slots-remote-full-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); - let extras: Vec = (1..3) - .map(|index| { - scan(&dir, index..index + 1, &format!("extra-{index}"), &|_| true) - .0 - .expect("scan") - .expect("extra") - }) - .collect(); - // Each clock read is past the cap, so the second read releases the pause. - let start = Instant::now(); - let ticks = std::sync::atomic::AtomicU32::new(0); - let clock = move || { - let n = ticks.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - start + PRESSURE_WAIT * (n + 1) - }; - let gate = remote_gate(&dir, true, Some(true)).with_clock(clock); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - drop(fill); - let (slot, err, _) = waiter.join().expect("join"); - assert_eq!(slot.index, 0); - assert_eq!(err, format!("dx: {}\n", wait_message("fill and 2 more"))); - drop(extras); - } - - #[derive(Debug)] - struct Switch(std::sync::atomic::AtomicBool); - - impl Pressure for Switch { - fn high(&self) -> Option { - Some(self.0.load(std::sync::atomic::Ordering::SeqCst)) - } - } - - /// A clock that starts now and moves only when told. - fn fake_clock() -> ( - impl Fn() -> Instant + Send + Sync + 'static, - Arc>, - ) { - let start = Instant::now(); - let offset = Arc::new(Mutex::new(Duration::ZERO)); - let read = offset.clone(); - (move || start + *read.lock().expect("clock"), offset) - } - - #[test] - fn a_new_gate_reads_no_host_pressure() { - let gate = Gate::new(PathBuf::from("/slots"), 1, "g".to_owned(), false); - assert_eq!(gate.pressure.high(), None); - } - - fn base_gate(dir: &Path, high: Arc) -> Gate { - Gate::new(dir.to_path_buf(), 2, "gate".to_owned(), true).with_pressure(high) - } - - #[test] - fn a_free_base_slot_waits_under_pressure_while_another_is_held() { - let scratch = dx_test_scratch::scratch("dx-slots-base-pressure-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); - let switch = Arc::new(Switch(std::sync::atomic::AtomicBool::new(true))); - let (waiter, rx) = spawn_take(base_gate(&dir, switch.clone())); - announced(&rx); - std::thread::sleep(POLL * 2); - assert!(!waiter.is_finished()); - switch.0.store(false, std::sync::atomic::Ordering::SeqCst); - let (slot, err, events) = waiter.join().expect("join"); - assert_eq!(slot.index, 1); - assert_eq!( - err, - format!( - "dx: {}\n", - wait_message("fill; paused under memory pressure") - ) - ); - assert!(events.contains("\"reason\":\"pressure\""), "{events}"); - drop(fill); - } - - #[test] - fn the_pause_ends_after_the_cap_even_under_pressure() { - let scratch = dx_test_scratch::scratch("dx-slots-base-cap-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); - let (clock, offset) = fake_clock(); - let gate = base_gate(&dir, Arc::new(Fixed(Some(true)))).with_clock(clock); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - std::thread::sleep(POLL * 2); - assert!(!waiter.is_finished()); - *offset.lock().expect("clock") = PRESSURE_WAIT; - let (slot, _, _) = waiter.join().expect("join"); - assert_eq!(slot.index, 1); - drop(fill); - } - - #[test] - fn nothing_pauses_when_no_other_slot_is_held_or_there_is_no_signal() { - let scratch = dx_test_scratch::scratch("dx-slots-base-free-"); - let dir = scratch.path().join("slots"); - let gate = base_gate(&dir, Arc::new(Fixed(Some(true)))); - let (slot, err, _) = take_gate(&gate); - assert_eq!((slot.index, err.as_str()), (0, "")); - let blind = base_gate(&dir, Arc::new(Fixed(None))); - let (other, err, _) = take_gate(&blind); - assert_eq!((other.index, err.as_str()), (1, "")); - } - - #[test] - fn a_normal_wait_reports_the_held_reason() { - let scratch = dx_test_scratch::scratch("dx-slots-base-held-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); - let gate = Gate::new(dir.clone(), 1, "gate".to_owned(), true) - .with_pressure(Arc::new(Fixed(Some(false)))); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - drop(fill); - let (_, _, events) = waiter.join().expect("join"); - assert!(events.contains("\"reason\":\"held\""), "{events}"); - } - - #[test] - fn a_local_rules_dx_gate_counts_a_held_extra_but_not_the_reserve() { - let scratch = dx_test_scratch::scratch("dx-slots-local-counted-"); - let dir = scratch.path().join("slots"); - let layout = Gate::new(dir.clone(), 4, "r".to_owned(), false) - .reserving(6) - .extra(4, false) - .layout(); - let take = |index: usize| { - scan(&dir, index..index + 1, "other", &|_| true) - .0 - .expect("scan") - .expect("slot") - }; - let reserve = take(4); - assert_eq!(held(&dir, layout.counted(), 0), 1); - let extra = take(6); - assert_eq!(held(&dir, layout.counted(), 0), 2); - let base = take(1); - assert_eq!(held(&dir, layout.counted(), 0), 3); - drop((reserve, extra, base)); - } - - #[test] - fn extra_grants_are_spaced_out() { - let scratch = dx_test_scratch::scratch("dx-slots-remote-spacing-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); - std::fs::write(extra_mark(&dir), "earlier").expect("mark"); - let gate = remote_gate(&dir, true, Some(false)); - let waiting = gate.clone(); - let (waiter, rx) = spawn_take(waiting); - announced(&rx); - std::thread::sleep(POLL * 2); - assert!(free(&dir, 1) && free(&dir, 2)); - drop(fill); - let base = waiter.join().expect("join").0; - assert_eq!(base.index, 0); - let old = SystemTime::now() - EXTRA_SPACING - Duration::from_secs(1); - File::options() - .write(true) - .open(extra_mark(&dir)) - .and_then(|file| file.set_modified(old)) - .expect("age mark"); - let (extra, err, _) = take_gate(&gate); - assert_eq!((extra.index, err.as_str()), (1, "")); - } - - #[test] - fn a_held_extra_counts_toward_the_share() { - alone("a_held_extra_counts_toward_the_share", || { - let scratch = dx_test_scratch::scratch("dx-slots-remote-share-"); - let dir = scratch.path().join("slots"); - let gate = remote_gate(&dir, true, Some(false)).sharing(12, 12 << 30, 0); - let (base, _, _) = take_gate(&gate); - let (extra, _, _) = take_gate(&gate); - assert_eq!(extra.index, 1); - let share = extra.share().expect("share"); - assert_eq!((share.cpus, share.memory_mb), (6, 6 << 10)); - drop(base); - let local = remote_gate(&dir, false, None).sharing(12, 12 << 30, 0); - let (again, _, _) = take_gate(&local); - assert_eq!(again.index, 0); - assert_eq!(again.share().expect("share").memory_mb, 6 << 10); - }); - } - - #[test] - fn share_splits_the_host_over_held_slots() { - let share = |cpus, memory, reserved, held| { - let share = local_share(cpus, memory, reserved, held); - (share.cpus, share.memory_mb) - }; - assert_eq!(share(12, 19 << 30, 0, 1), (12, 19 << 10)); - assert_eq!(share(12, 19 << 30, 0, 2), (6, 9728)); - assert_eq!(share(12, 19 << 30, 0, 4), (3, 4864)); - assert_eq!(share(4, 8 << 30, 0, 4), (2, 2048)); - assert_eq!(share(1, 8 << 30, 0, 1), (2, 8192)); - assert_eq!(share(12, 19 << 30, 6 << 30, 2), (6, 6656)); - assert_eq!(share(12, 4 << 30, 6 << 30, 2), (6, 1)); - assert_eq!(share(12, 19 << 30, 0, 0), (12, 19 << 10)); - } - - #[cfg(unix)] - #[test] - fn a_child_forked_while_a_slot_is_held_keeps_it_until_exec() { - use std::io::Read; - use std::os::fd::AsRawFd; - use std::os::unix::net::UnixStream; - use std::os::unix::process::CommandExt; - let scratch = dx_test_scratch::scratch("dx-slots-fork-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - let slot = try_take(&dir, 1, "held").expect("try").expect("free"); - let (mut reader, writer) = UnixStream::pair().expect("pair"); - let fd = writer.as_raw_fd(); - let spawner = std::thread::spawn(move || { - let mut cmd = Process::new("/bin/sh"); - cmd.args(["-c", "exit 0"]); - // SAFETY: the hook only calls write and nanosleep, both async-signal-safe. - unsafe { - cmd.pre_exec(move || { - let byte = 1u8; - libc::write(fd, (&byte as *const u8).cast(), 1); - let nap = libc::timespec { - tv_sec: 0, - tv_nsec: 150_000_000, - }; - libc::nanosleep(&nap, std::ptr::null_mut()); - Ok(()) - }); - } - let mut child = cmd.spawn().expect("spawn"); - drop(writer); - child.wait().expect("wait"); - }); - let mut byte = [0u8; 1]; - reader.read_exact(&mut byte).expect("forked"); - drop(slot); - assert!( - try_take(&dir, 1, "probe").expect("try").is_none(), - "the forked child holds the lock until it execs" - ); - assert!(slot_settles_free(&dir, 1)); - spawner.join().expect("spawner"); - } - - #[test] - fn held_counts_locked_slots_and_the_caller() { - let scratch = dx_test_scratch::scratch("dx-slots-held-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - assert_eq!(held(&dir, 0..3, 0), 1); - let mut child = spawn_holder(&dir); - assert_eq!(held(&dir, 0..3, 1), 2); - assert_eq!(held(&dir, 0..3, 0), 1); - let _ = child.kill(); - let _ = child.wait(); - std::fs::write(holder_path(&dir, 2), "gone").expect("stale holder"); - assert!(holder_path(&dir, 0).exists()); - assert_eq!(held(&dir, 0..3, 1), 1); - assert!(slot_settles_free(&dir, 1)); - } - - #[test] - fn a_taken_slot_sets_the_share_until_it_drops() { - alone( - "a_taken_slot_sets_the_share_until_it_drops", - take_sets_the_share, - ); - } - - fn take_sets_the_share() { - let scratch = dx_test_scratch::scratch("dx-slots-take-share-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 2, "me".to_owned(), false).sharing(600, 9 << 30, 1 << 30); - let other = try_take(&dir, 2, "other").expect("try").expect("other"); - let (slot, _, _) = take_gate(&gate); - let want = dx_process::LocalShare { - cpus: 300, - memory_mb: 4096, - }; - assert_eq!(slot.share(), Some(want)); - assert_eq!(dx_process::share(), Some(want)); - drop(slot); - assert_eq!(dx_process::share(), None); - drop(other); - let plain = Gate::new(dir, 2, "plain".to_owned(), false); - let (slot, _, _) = take_gate(&plain); - assert_eq!(slot.share(), None); - assert_eq!(dx_process::share(), None); - } - - #[test] - fn share_shrinks_when_another_slot_is_taken() { - alone("share_shrinks_when_another_slot_is_taken", || { - let scratch = dx_test_scratch::scratch("dx-slots-live-share-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 2, "me".to_owned(), false).sharing(12, 12 << 30, 0); - let (slot, _, _) = take_gate(&gate); - let cpus = || dx_process::share().map(|share| share.cpus); - assert_eq!(cpus(), Some(12)); - let other = try_take(&dir, 2, "other").expect("try").expect("other"); - assert_eq!(cpus(), Some(6)); - drop(other); - assert_eq!(cpus(), Some(12)); - drop(slot); - assert_eq!(cpus(), None); - }); - } - - #[test] - fn slot_runner_exports_the_current_share() { - use dx_process::Runner as _; - alone("slot_runner_exports_the_current_share", || { - let scratch = dx_test_scratch::scratch("dx-slots-live-env-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 2, "me".to_owned(), false).sharing(12, 12 << 30, 0); - let (slot, _, _) = take_gate(&gate); - let inner = EnvRecorder::default(); - let held = slot.runner(&inner); - held.run(&["bazel".to_owned()], scratch.path(), &[]) - .expect("run"); - let other = try_take(&dir, 2, "other").expect("try").expect("other"); - held.run(&["bazel".to_owned()], scratch.path(), &[]) - .expect("run"); - drop(other); - let shares: Vec = inner - .seen - .borrow() - .iter() - .flat_map(|(_, env)| env.iter()) - .filter(|(name, _)| name == SHARE_ENV_VAR) - .map(|(_, value)| value.clone()) - .collect(); - assert_eq!(shares, vec!["cpu=12,memory=12288", "cpu=6,memory=6144"]); - }); - } - - #[test] - fn a_nested_call_reuses_the_parent_share() { - alone( - "a_nested_call_reuses_the_parent_share", - nested_reuses_the_share, - ); - } - - fn nested_reuses_the_share() { - let env = |slot: Option<&'static str>, share: &'static str| { - move |name: &str| match name { - SLOT_ENV_VAR => slot.map(str::to_owned), - SHARE_ENV_VAR => Some(share.to_owned()), - _ => None, - } - }; - dx_process::set_share(None); - inherit_share(&env(None, "cpu=5,memory=700")); - assert_eq!(dx_process::share(), None); - inherit_share(&env(Some("1"), "cpu=5,memory=bad")); - assert_eq!(dx_process::share(), None); - inherit_share(&env(Some("1"), "cpu=5,memory=700")); - let want = dx_process::LocalShare { - cpus: 5, - memory_mb: 700, - }; - assert_eq!(dx_process::share(), Some(want)); - dx_process::clear_share(&want); - } - - fn rules_dx_layout(prefix: &str) -> tempfile::TempDir { - let scratch = dx_test_scratch::scratch(prefix); - std::fs::create_dir_all(scratch.path().join("cli/cli")).expect("dirs"); - std::fs::write(scratch.path().join("cli/cli/BUILD.bazel"), "").expect("build"); - std::fs::write( - scratch.path().join("MODULE.bazel"), - "module(\n name = \"rules_dx\",\n version = \"0.0.0\",\n)\n", - ) - .expect("module"); - scratch - } - - #[test] - fn consumer_workspaces_reserve_nothing() { - let none = |_: &str| None; - assert_eq!(nested_reserve(Path::new("/ws"), &none), 0); - let scratch = dx_test_scratch::scratch("dx-slots-consumer-"); - std::fs::create_dir_all(scratch.path().join("cli/cli")).expect("dirs"); - std::fs::write(scratch.path().join("cli/cli/BUILD.bazel"), "").expect("build"); - std::fs::write( - scratch.path().join("MODULE.bazel"), - "module(\n name = \"my_app\",\n)\nbazel_dep(name = \"rules_dx\", version = \"1.0.0\")\n", - ) - .expect("module"); - assert_eq!(nested_reserve(scratch.path(), &none), 0); - } - - #[test] - fn rules_dx_gate_spans_the_whole_pool() { - let scratch = rules_dx_layout("dx-slots-pool-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - let memory = available_memory().expect("memory"); - let gate = - Gate::from_env(scratch.path(), &get, false, &[], BuildSlots::Auto).expect("gate"); - let resources = gate.resources.expect("resources"); - let cpus = std::thread::available_parallelism().expect("cpus").get(); - assert_eq!(resources.cpus, u64::try_from(cpus).expect("u64")); - assert_eq!((resources.memory, resources.reserved), (memory, 6 << 30)); - assert_eq!(gate.count, slot_count(memory, 6 << 30)); - assert_eq!(gate.pool, slot_count(memory, 0)); - let consumer = - Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Auto).expect("gate"); - assert_eq!(consumer.pool, consumer.count); - let narrow = Gate::new(PathBuf::from("/slots"), 3, "r".to_owned(), false).reserving(1); - assert_eq!(narrow.pool, 3); - } - - #[test] - fn rules_dx_reserves_the_default_nested_servers() { - let scratch = rules_dx_layout("dx-slots-rules-dx-"); - assert_eq!(nested_reserve(scratch.path(), &|_| None), 3 * (2 << 30)); - } - - #[test] - fn rules_dx_reserve_is_zero_when_tag_filters_exclude_nested_bazel() { - for (filters, reserved) in [ - ("-resources:nested_bazel:1,-exclusive", false), - ("-resources:nested_bazel", false), - ("-exclusive", true), - ("smoke", false), - ("smoke,resources:nested_bazel:1", true), - ] { - let scratch = rules_dx_layout("dx-slots-tag-filters-"); - std::fs::write( - scratch.path().join(".bazelrc"), - format!("test --test_tag_filters={filters}\n"), - ) - .expect("rc"); - let want = if reserved { 3 * (2 << 30) } else { 0 }; - assert_eq!(nested_reserve(scratch.path(), &|_| None), want, "{filters}"); - } - } - - #[test] - fn rules_dx_reserve_follows_the_nested_slot_env() { - let scratch = rules_dx_layout("dx-slots-rules-dx-env-"); - let with = |value: &'static str| { - move |name: &str| (name == NESTED_BAZEL_SLOTS_ENV).then(|| value.to_owned()) - }; - assert_eq!(nested_reserve(scratch.path(), &with("5")), 10 << 30); - for fallback in ["0", "x", ""] { - assert_eq!(nested_reserve(scratch.path(), &with(fallback)), 6 << 30); - } - } - - #[test] - fn auto_gate_reserve_is_the_shared_reserve_range() { - for gib in [4_u64, 8, 12, 16, 24, 64] { - let memory = gib << 30; - let reserved = u64::from(DEFAULT_NESTED_BAZEL_SLOTS as u16) * BYTES_PER_NESTED_BAZEL; - let size = sizing(BuildSlots::Auto, memory, reserved).expect("sizing"); - let range = dx_process::slot_pool::reserve_range(memory, DEFAULT_NESTED_BAZEL_SLOTS); - assert_eq!(size.count..size.pool, range, "{gib} GiB"); - } - } - - #[test] - fn a_consumer_gate_leaves_the_held_reserve_alone() { - let scratch = dx_test_scratch::scratch("dx-slots-reserve-held-"); - let dir = scratch.path().join("dx").join("slots"); - let memory = 16_u64 << 30; - let reserve = dx_process::slot_pool::reserve_range(memory, DEFAULT_NESTED_BAZEL_SLOTS); - let held = dx_process::slot_pool::hold_reserve(&dir, reserve.clone()); - assert!(held.busy.is_empty()); - let count = reserve.start; - let slots: Vec = (0..count) - .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("slot")) - .collect(); - assert!(slots.iter().all(|slot| slot.index < count)); - for index in reserve { - assert!(!free(&dir, index), "reserve index {index} is held"); - } - } - - #[test] - fn slot_memory_parses_sizes_and_scales_counts() { - assert_eq!(parse_slot_memory("4g"), Some(4 << 30)); - assert_eq!(parse_slot_memory(" 512M "), Some(512 << 20)); - assert_eq!(parse_slot_memory("2048"), Some(2048 << 20)); - for bad in ["", "0", "g", "-1g", "1.5g", "4x"] { - assert_eq!(parse_slot_memory(bad), None, "{bad}"); - } - let env = |value: &'static str| { - move |name: &str| (name == SLOT_MEMORY_ENV).then(|| value.to_owned()) - }; - assert_eq!(slot_bytes(&env("6g")), 6 << 30); - assert_eq!(slot_bytes(&env("nope")), BYTES_PER_SLOT); - assert_eq!(slot_bytes(&|_: &str| None), BYTES_PER_SLOT); - let memory = per_slot_scaled(24 << 30, 6 << 30); - assert_eq!(slot_count(memory, 0), 4); - assert_eq!( - slot_count(per_slot_scaled(24 << 30, 1 << 30), 0), - MAX_SLOTS.min(24) - ); - } - - #[test] - fn available_memory_text_parses() { - let text = "MemTotal: 4096 kB\nMemAvailable: 2048 kB\n"; - assert_eq!(parse_mem_available(text), Some(2 << 20)); - assert_eq!(parse_mem_available("MemAvailable: x kB\n"), None); - assert_eq!(parse_mem_available("MemTotal: 1 kB\n"), None); - } - - #[test] - fn memory_text_parses() { - assert_eq!( - parse_meminfo("MemFree: 1 kB\nMemTotal: 2048 kB\n"), - Some(2048 * 1024) - ); - assert_eq!(parse_meminfo("MemFree: 1 kB\n"), None); - assert_eq!(parse_cgroup_limit("8589934592\n"), Some(8 << 30)); - assert_eq!(parse_cgroup_limit("max\n"), None); - assert_eq!(parse_cgroup_limit("9223372036854771712\n"), None); - assert_eq!(parse_cgroup_limit("9223372036854710272\n"), None); - assert_eq!(parse_cgroup_limit("-1\n"), None); - assert_eq!(parse_cgroup_limit("2147483648\n"), Some(2 << 30)); - } - - #[test] - fn extras_follow_the_cgroup_limit() { - let scratch = dx_test_scratch::scratch("dx-cgroup-extras-"); - let root = scratch.path(); - std::fs::create_dir_all(root.join("a")).expect("dirs"); - std::fs::write(root.join("a/memory.max"), format!("{}\n", 6u64 << 30)).expect("write"); - let host = 19u64 << 30; - let limit = cgroup_limit_in("0::/a\n", root).expect("limit"); - let memory = host.min(limit); - let extras = remote_slot_count(memory, 0).saturating_sub(slot_count(memory, 0)); - let host_extras = remote_slot_count(host, 0).saturating_sub(slot_count(host, 0)); - assert!(extras < host_extras); - assert_eq!(extras, 2); - } - - #[test] - fn cgroup_limit_reads_v1_and_v2() { - let put = |root: &Path, rel: &str, text: &str| { - let path = root.join(rel); - std::fs::create_dir_all(path.parent().unwrap()).expect("dirs"); - std::fs::write(path, text).expect("write"); - }; - let scratch = dx_test_scratch::scratch("dx-cgroup-v2-"); - let root = scratch.path(); - put(root, "a/memory.max", "1000\n"); - std::fs::create_dir_all(root.join("a/b")).expect("dirs"); - assert_eq!(cgroup_limit_in("0::/a/b\n", root), Some(1000)); - assert_eq!(cgroup_limit_in("", root), None); - - let scratch = dx_test_scratch::scratch("dx-cgroup-v1-"); - let root = scratch.path(); - put(root, "memory/memory.limit_in_bytes", "2000\n"); - assert_eq!(cgroup_limit_in("4:memory:/docker/x\n", root), Some(2000)); - assert_eq!(cgroup_limit_in("5:cpu,memory:/x\n", root), Some(2000)); - assert_eq!(cgroup_limit_in("5:cpu:/x\n", root), None); - put(root, "a/memory.max", "3000\n"); - let hybrid = "4:memory:/docker/x\n0::/a\n"; - assert_eq!(cgroup_limit_in(hybrid, root), Some(2000)); - put(root, "a/memory.max", "1500\n"); - assert_eq!(cgroup_limit_in(hybrid, root), Some(1500)); - put(root, "memory/p/c/memory.limit_in_bytes", "5000\n"); - put(root, "memory/p/memory.limit_in_bytes", "700\n"); - assert_eq!(cgroup_limit_in("4:memory:/p/c\n", root), Some(700)); - - let scratch = dx_test_scratch::scratch("dx-cgroup-v1-unlimited-"); - let root = scratch.path(); - put( - root, - "memory/memory.limit_in_bytes", - "9223372036854771712\n", - ); - assert_eq!(cgroup_limit_in("4:memory:/x\n", root), None); - } - - #[derive(Default)] - struct EnvRecorder { - #[allow(clippy::type_complexity)] - seen: RefCell)>>, - } - - impl EnvRecorder { - fn record(&self, method: &'static str, env: &[(&str, &str)]) { - let env = env - .iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) - .collect(); - self.seen.borrow_mut().push((method, env)); - } - } - - impl dx_process::Runner for EnvRecorder { - fn run( - &self, - _argv: &[String], - _cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - self.record("run", env); - Ok(dx_process::ChildStatus::exited(0)) - } - - fn run_hermetic( - &self, - _argv: &[String], - _cwd: &Path, - env: &[(&str, &str)], - ) -> io::Result { - self.record("run_hermetic", env); - Ok(dx_process::ChildStatus::exited(3)) - } - - fn run_withdrawable( - &self, - _argv: &[String], - _cwd: &Path, - env: &[(&str, &str)], - _withdraw: &dyn Fn() -> bool, - ) -> io::Result> { - self.record("run_withdrawable", env); - Ok(Some(dx_process::ChildStatus::exited(4))) - } - - fn free_disk_bytes(&self, _path: &Path) -> Option { - Some(9) - } - - fn pinned_tool(&self, _var: &str) -> dx_process::PinnedVar { - dx_process::PinnedVar::NotAbsolute - } - } - - #[test] - fn slot_runner_forwards_the_pinned_tool_state() { - use dx_process::Runner as _; - let scratch = dx_test_scratch::scratch("dx-slots-pinned-"); - let dir = scratch.path().join("slots"); - let slot = try_take(&dir, 1, "pinned").expect("try").expect("free"); - let inner = EnvRecorder::default(); - let held = slot.runner(&inner); - assert_eq!( - held.pinned_tool("DX_X_BIN"), - dx_process::PinnedVar::NotAbsolute - ); - } - - #[test] - fn slot_runner_marks_children_while_the_slot_is_held() { - use dx_process::Runner as _; - let scratch = dx_test_scratch::scratch("dx-slots-env-"); - let dir = scratch.path().join("slots"); - let slot = try_take(&dir, 1, "env").expect("try").expect("free"); - let inner = EnvRecorder::default(); - let held = slot.runner(&inner); - let argv = ["bazel".to_owned()]; - let cwd = scratch.path(); - let env = [("KEEP", "yes")]; - assert_eq!(held.run(&argv, cwd, &env).expect("run").code, Some(0)); - assert_eq!( - held.run_withdrawable(&argv, cwd, &env, &|| true) - .expect("withdrawable") - .map(|status| status.code), - Some(Some(4)) - ); - assert_eq!( - held.run_hermetic(&argv, cwd, &env).expect("hermetic").code, - Some(3) - ); - assert_eq!(held.free_disk_bytes(cwd), Some(9)); - let marked = vec![ - ("KEEP".to_owned(), "yes".to_owned()), - (SLOT_ENV_VAR.to_owned(), "1".to_owned()), - ]; - let plain = vec![("KEEP".to_owned(), "yes".to_owned())]; - assert_eq!( - *inner.seen.borrow(), - vec![ - ("run", marked.clone()), - ("run_withdrawable", marked), - ("run_hermetic", plain), - ] - ); - } - - #[test] - fn only_bazel_commands_take_slots() { - for command in [Command::Build, Command::Test, Command::Coverage] { - assert!(needs_slot(command)); - } - for command in [Command::Run, Command::Watch, Command::Update] { - assert!(!needs_slot(command)); - } - for command in [ - Command::Lint, - Command::Typecheck, - Command::Format, - Command::Deploy, - Command::Generate, - Command::Sync, - Command::Security, - Command::License, - Command::Docs, - ] { - assert!(needs_slot(command)); - } - for command in [Command::Status, Command::Worktree, Command::Completion] { - assert!(!needs_slot(command)); - } - } - - #[test] - fn slot_runner_passes_the_share_to_children() { - use dx_process::Runner as _; - let scratch = dx_test_scratch::scratch("dx-slots-share-env-"); - let dir = scratch.path().join("slots"); - let mut slot = try_take(&dir, 1, "env").expect("try").expect("free"); - slot.sizing = Some(Arc::new(ShareSizing { - dir: dir.clone(), - indices: vec![0], - own: 0, - resources: Resources { - cpus: 7, - memory: 777 << 20, - reserved: 0, - }, - })); - let inner = EnvRecorder::default(); - let held = slot.runner(&inner); - held.run(&["bazel".to_owned()], scratch.path(), &[]) - .expect("run"); - assert_eq!( - *inner.seen.borrow(), - vec![( - "run", - vec![ - (SLOT_ENV_VAR.to_owned(), "1".to_owned()), - (SHARE_ENV_VAR.to_owned(), "cpu=7,memory=777".to_owned()), - ] - )] - ); - } - - #[test] - fn a_gate_ignores_slot_env_set_after_it_was_built() { - let scratch = dx_test_scratch::scratch("dx-slots-gate-env-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let nested = std::cell::Cell::new(false); - let get = |name: &str| match name { - "XDG_CACHE_HOME" => Some(cache.clone()), - SLOT_ENV_VAR => nested.get().then(|| "1".to_owned()), - _ => None, - }; - let gate = - Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Auto).expect("gate"); - nested.set(true); - assert!(Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Auto).is_none()); - let slot = with_gate(Some(gate), || { - take(Command::Run, &mut Vec::new(), &mut Vec::new()) - }); - assert!(matches!( - slot, - Ok(Some(Slot { - sizing: Some(_), - .. - })) - )); - assert!( - scratch - .path() - .join("dx") - .join("slots") - .join("slot-0.lock") - .exists() - ); - } - - fn counting( - message: Option<&'static str>, - ) -> ( - Arc, - impl Fn() -> Option + Send + Sync + 'static, - ) { - let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let counter = Arc::clone(&calls); - let reclaim = move || { - counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - message.map(str::to_owned) - }; - (calls, reclaim) - } - - const RECLAIMED: &str = - "low disk: 5.0 GiB free; removed the output bases of free slot(s) 2; 30.0 GiB free now"; - - #[test] - fn take_runs_the_reclaimer_once_and_reports_it() { - for json in [true, false] { - let scratch = dx_test_scratch::scratch("dx-slots-reclaim-"); - let (calls, reclaim) = counting(Some(RECLAIMED)); - let gate = Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), json) - .reclaiming(reclaim); - let (_slot, err, events) = take_gate(&gate); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!(err, format!("dx: {RECLAIMED}\n")); - if !json { - assert!(events.is_empty(), "text mode writes no events"); - continue; - } - let notices: Vec = events - .lines() - .map(|line| serde_json::from_str(line).expect("json")) - .collect(); - assert_eq!(notices.len(), 1, "{events}"); - assert_eq!(notices[0]["event"], "notice"); - assert_eq!(notices[0]["severity"], "warning"); - assert_eq!(notices[0]["code"], "disk_low"); - assert_eq!(notices[0]["message"], RECLAIMED); - assert_eq!(notices[0]["related_command"], "test"); - } - } - - #[test] - fn hold_for_a_command_without_a_build_slot_never_reclaims() { - let scratch = dx_test_scratch::scratch("dx-slots-no-reclaim-"); - let (calls, reclaim) = counting(Some(RECLAIMED)); - let gate = - Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), true).reclaiming(reclaim); - for command in [ - Command::Worktree, - Command::Status, - Command::Clean, - Command::Watch, - ] { - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = gate.hold(command, &mut err, &mut events).expect("hold"); - assert!(slot.is_none(), "{command:?}"); - assert!(err.is_empty() && events.is_empty(), "{command:?}"); - } - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); - let mut err = Vec::new(); - let slot = gate - .hold(Command::Build, &mut err, &mut Vec::new()) - .expect("hold"); - assert!(slot.is_some()); - assert_eq!( - calls.load(std::sync::atomic::Ordering::SeqCst), - 1, - "a build reclaims" - ); - } - - #[test] - fn a_quiet_reclaim_prints_nothing() { - let scratch = dx_test_scratch::scratch("dx-slots-quiet-reclaim-"); - let (calls, reclaim) = counting(None); - let gate = - Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), true).reclaiming(reclaim); - let (_slot, err, events) = take_gate(&gate); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); - assert!(err.is_empty(), "{err}"); - assert!(events.is_empty(), "{events}"); - } - - #[test] - fn take_reclaims_when_slots_are_disabled() { - let scratch = dx_test_scratch::scratch("dx-slots-disabled-reclaim-"); - let blocker = scratch.path().join("slots"); - std::fs::write(&blocker, b"").expect("blocker"); - let (calls, reclaim) = counting(Some(RECLAIMED)); - let gate = Gate::new(blocker, 1, "me".to_owned(), false).reclaiming(reclaim); - let mut err = Vec::new(); - let slot = gate - .take(Command::Test, &mut err, &mut Vec::new()) - .expect("take"); - assert!(slot.is_none()); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); - let text = String::from_utf8(err).expect("utf8"); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 2, "{text}"); - assert!(lines[0].starts_with("dx: build slots disabled: "), "{text}"); - assert_eq!(lines[1], format!("dx: {RECLAIMED}")); - } - - #[test] - fn a_nested_take_without_a_gate_never_reclaims() { - let scratch = dx_test_scratch::scratch("dx-slots-nested-reclaim-"); - let (calls, reclaim) = counting(Some(RECLAIMED)); - let gate = - Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), false).reclaiming(reclaim); - with_gate(Some(gate), || { - with_gate(None, || { - take(Command::Build, &mut Vec::new(), &mut Vec::new()).expect("take") - }) - }); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); - } - - #[test] - fn take_without_a_gate_takes_nothing() { - let scratch = dx_test_scratch::scratch("dx-slots-no-gate-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 1, "me".to_owned(), false); - with_gate(Some(gate), || { - let slot = take(Command::Run, &mut Vec::new(), &mut Vec::new()).expect("take"); - assert!(slot.is_some()); - assert!( - with_gate(None, || take( - Command::Run, - &mut Vec::new(), - &mut Vec::new() - )) - .expect("none") - .is_none() - ); - }); - assert!( - take(Command::Run, &mut Vec::new(), &mut Vec::new()) - .expect("none") - .is_none() - ); - assert!(slot_settles_free(&dir, 1)); - } - - #[test] - fn a_phase_takes_nothing_until_needed() { - let scratch = dx_test_scratch::scratch("dx-slots-phase-lazy-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 1, "me".to_owned(), false); - with_gate(Some(gate), || { - let mut phase = Phase::new(Command::Run); - let (mut err, mut events) = (Vec::new(), Vec::new()); - assert!(phase.need(false, &mut err, &mut events).unwrap().is_none()); - assert!(phase.slot().is_none()); - assert!(slot_settles_free(&dir, 1)); - }); - } - - #[test] - fn a_phase_takes_once() { - let scratch = dx_test_scratch::scratch("dx-slots-phase-once-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir, 1, "me".to_owned(), false); - with_gate(Some(gate), || { - let mut phase = Phase::new(Command::Run); - let (mut err, mut events) = (Vec::new(), Vec::new()); - assert!(phase.need(true, &mut err, &mut events).unwrap().is_some()); - assert!(phase.need(true, &mut err, &mut events).unwrap().is_some()); - assert!(err.is_empty()); - }); - } - - #[test] - fn a_phase_without_a_gate_holds_nothing() { - let mut phase = Phase::new(Command::Run); - let (mut err, mut events) = (Vec::new(), Vec::new()); - assert!(phase.need(true, &mut err, &mut events).unwrap().is_none()); - assert!(phase.need(true, &mut err, &mut events).unwrap().is_none()); - assert!(phase.slot().is_none()); - } - - #[test] - fn a_released_phase_frees_its_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-phase-release-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 1, "me".to_owned(), false); - with_gate(Some(gate), || { - let mut phase = Phase::new(Command::Run); - let (mut err, mut events) = (Vec::new(), Vec::new()); - assert!(phase.need(true, &mut err, &mut events).unwrap().is_some()); - phase.release(); - assert!(phase.slot().is_none()); - assert!(slot_settles_free(&dir, 1)); - }); - } - - #[test] - fn share_report_counts_held_slots_read_only() { - let scratch = dx_test_scratch::scratch("dx-slots-report-"); - let cache = scratch.path().display().to_string(); - let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - let dir = slots_dir(&get).expect("dir"); - std::fs::create_dir_all(&dir).expect("dir"); - let report = |get: &dyn Fn(&str) -> Option| { - share_report( - scratch.path(), - get, - BuildSlots::Count(2.try_into().expect("two")), - ) - }; - let idle = report(&get).expect("report"); - assert_eq!((idle.held, idle.count), (0, 2)); - let _held = try_take(&dir, 2, "me").expect("try").expect("slot"); - std::fs::write(holder_path(&dir, 0), "me").expect("holder"); - let busy = report(&get).expect("report"); - assert_eq!(busy.held, 1); - assert!(busy.next.cpus <= idle.next.cpus); - assert!(!slot_path(&dir, 1).exists(), "the probe creates no slot"); - } - - #[test] - fn held_count_includes_extra_slots() { - let scratch = dx_test_scratch::scratch("dx-slots-extra-"); - let size = Sizing { - count: 2, - pool: 3, - extras: 1, - }; - assert_eq!(size.counted().collect::>(), [0, 1, 3]); - let _extra = lock_slot(&slot_path(scratch.path(), 3), false).expect("lock"); - assert_eq!(held(scratch.path(), size.counted(), usize::MAX), 2); - } - - #[test] - fn cache_dir_follows_the_environment() { - let get = |name: &str| match name { - "XDG_CACHE_HOME" => Some("/x".to_owned()), - _ => None, - }; - assert_eq!(slots_dir(&get), Some(PathBuf::from("/x/dx/slots"))); - let home = |name: &str| (name == "HOME").then(|| "/h".to_owned()); - assert_eq!(slots_dir(&home), Some(PathBuf::from("/h/.cache/dx/slots"))); - let local = |name: &str| (name == "LOCALAPPDATA").then(|| "/l".to_owned()); - assert_eq!(slots_dir(&local), Some(PathBuf::from("/l/dx/slots"))); - let empty_home = |name: &str| match name { - "HOME" => Some(String::new()), - "LOCALAPPDATA" => Some("/l".to_owned()), - _ => None, - }; - assert_eq!(slots_dir(&empty_home), Some(PathBuf::from("/l/dx/slots"))); - assert_eq!(slots_dir(&|_| None), None); - } - - #[test] - fn a_second_taker_waits_until_the_first_drops() { - let scratch = dx_test_scratch::scratch("dx-slots-wait-"); - let dir = scratch.path().join("slots"); - let first = acquire(&dir, 1, "tree-a", &mut |_| {}).expect("first"); - assert_eq!(first.index, 0); - let mut seen = Vec::new(); - let second_dir = dir.clone(); - let (sender, receiver) = std::sync::mpsc::channel(); - let waiter = std::thread::spawn(move || { - let mut holders = Vec::new(); - let slot = acquire(&second_dir, 1, "tree-b", &mut |holder| { - holders.push(holder.to_owned()); - let _ = sender.send(()); - }) - .expect("second"); - (slot.index, holders) - }); - announced(&receiver); - drop(first); - let (index, holders) = waiter.join().expect("join"); - seen.extend(holders); - assert_eq!(index, 0); - assert_eq!(seen, vec!["tree-a".to_owned()]); - assert_eq!( - wait_message("tree-a"), - "waiting for a build slot (held by tree-a); see dx status, or set --build-slots or DX_BUILD_SLOTS" - ); - } - - #[test] - fn waiters_get_slots_in_arrival_order() { - let scratch = dx_test_scratch::scratch("dx-slots-order-"); - let dir = scratch.path().join("slots"); - let held = try_take(&dir, 1, "first").expect("try").expect("free"); - let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut notices = Vec::new(); - let mut waiters = Vec::new(); - for id in 0..3 { - let (sender, receiver) = std::sync::mpsc::channel(); - let dir = dir.clone(); - let order = order.clone(); - waiters.push(std::thread::spawn(move || { - let slot = acquire(&dir, 1, &format!("w{id}"), &mut |holder| { - sender.send(holder.to_owned()).expect("send"); - }) - .expect("slot"); - order.lock().expect("order").push(id); - std::thread::sleep(Duration::from_millis(50)); - drop(slot); - })); - notices.push( - receiver - .recv_timeout(Duration::from_secs(30)) - .expect("notice"), - ); - } - drop(held); - for waiter in waiters { - waiter.join().expect("join"); - } - assert_eq!(*order.lock().expect("order"), vec![0, 1, 2]); - assert_eq!( - notices, - vec![ - "first".to_owned(), - "first, 1 ahead in line".to_owned(), - "first, 2 ahead in line".to_owned(), - ] - ); - assert!(tickets(&dir).is_empty()); - } - - #[test] - fn a_newcomer_queues_behind_a_waiter() { - let scratch = dx_test_scratch::scratch("dx-slots-newcomer-"); - let dir = scratch.path().join("slots"); - let queued = enqueue(&dir, "").expect("queued"); - let (sender, receiver) = std::sync::mpsc::channel(); - let newcomer_dir = dir.clone(); - let newcomer = std::thread::spawn(move || { - acquire(&newcomer_dir, 1, "newcomer", &mut |holder| { - sender.send(holder.to_owned()).expect("send"); - }) - .expect("slot") - .index - }); - let notice = receiver - .recv_timeout(Duration::from_secs(30)) - .expect("notice"); - assert_eq!(notice, "another build, 1 ahead in line"); - let first = try_take(&dir, 1, "queued").expect("try").expect("free"); - drop(queued); - drop(first); - assert_eq!(newcomer.join().expect("join"), 0); - } - - #[test] - fn an_unlocked_ticket_does_not_block_and_is_swept() { - let scratch = dx_test_scratch::scratch("dx-slots-swept-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(queue_dir(&dir)).expect("queue"); - let dead = queue_dir(&dir).join(format!("ticket-{:020}.lock", 7)); - std::fs::write(&dead, b"").expect("dead ticket"); - std::fs::write(queue_dir(&dir).join("ticket-x.lock"), b"").expect("odd name"); - let slot = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("slot"); - assert_eq!(slot.index, 0); - assert!(!dead.exists()); - assert!(tickets(&dir).is_empty()); - } - - #[test] - fn a_leaving_ticket_never_deletes_the_next_waiters_want() { - let scratch = dx_test_scratch::scratch("dx-slots-leave-"); - let dir = scratch.path().join("slots"); - let mut first = enqueue(&dir, "").expect("first"); - let first_number = first.number; - let mut next = None; - first.leave(|| next = Some(enqueue(&dir, "").expect("next"))); - let next = next.expect("next ticket"); - assert_ne!(next.number, first_number); - drop(first); - assert!(next.path.exists()); - assert!(want_path(&next.path).exists()); - let listed = tickets(&dir); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].1, next.path); - } - - #[test] - fn a_dropped_ticket_leaves_no_file() { - let scratch = dx_test_scratch::scratch("dx-slots-drop-"); - let dir = scratch.path().join("slots"); - drop(enqueue(&dir, "").expect("ticket")); - assert!(tickets(&dir).is_empty()); - } - - #[test] - fn a_stale_ticket_does_not_block() { - let scratch = dx_test_scratch::scratch("dx-slots-stale-"); - let dir = scratch.path().join("slots"); - let stale = enqueue(&dir, "").expect("stale"); - let earlier = SystemTime::now() - Duration::from_secs(60); - stale - .file - .as_ref() - .expect("file") - .set_modified(earlier) - .expect("mtime"); - let slot = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("slot"); - assert_eq!(slot.index, 0); - assert!(stale.path.exists()); - } - - #[test] - fn a_broken_queue_still_gates() { - let scratch = dx_test_scratch::scratch("dx-slots-no-queue-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("slots"); - std::fs::write(queue_dir(&dir), b"").expect("blocker"); - assert!(enqueue(&dir, "").is_err()); - let held = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("held"); - let (sender, receiver) = std::sync::mpsc::channel(); - let waiter_dir = dir.clone(); - let waiter = std::thread::spawn(move || { - acquire(&waiter_dir, 1, "b", &mut |holder| { - sender.send(holder.to_owned()).expect("send"); - }) - .expect("slot") - .index - }); - let notice = receiver - .recv_timeout(Duration::from_secs(30)) - .expect("notice"); - assert_eq!(notice, "a"); - drop(held); - assert_eq!(waiter.join().expect("join"), 0); - } - - #[test] - fn a_freed_slot_clears_its_holder() { - let scratch = dx_test_scratch::scratch("dx-slots-holder-"); - let dir = scratch.path().join("slots"); - let a = try_take(&dir, 3, "a").expect("try").expect("a"); - let b = try_take(&dir, 3, "b").expect("try").expect("b"); - let c = try_take(&dir, 3, "c").expect("try").expect("c"); - assert_eq!(holder(&dir, 0..3, 2), "a and 2 more, 2 ahead in line"); - drop(a); - assert!(!holder_path(&dir, 0).exists()); - assert_eq!(holder(&dir, 0..3, 0), "b and 1 more"); - drop((b, c)); - assert_eq!(holder(&dir, 0..3, 0), "another build"); - } - - #[test] - fn a_free_slot_is_taken_without_waiting() { - let scratch = dx_test_scratch::scratch("dx-slots-free-"); - let dir = scratch.path().join("slots"); - let first = acquire(&dir, 2, "a", &mut |_| {}).expect("first"); - let second = acquire(&dir, 2, "b", &mut |_| panic!("must not wait")).expect("second"); - assert_eq!((first.index, second.index), (0, 1)); - } - - /// A writer that buffers everything and signals once on its first write. - /// - /// `Gate::take` writes the wait line from `on_wait`, which `acquire_reserving` runs only after - /// the waiter is queued and failed to take a slot, so the signal proves the waiter is in line. - struct Announce { - buf: Vec, - signal: Option>, - } - - impl Announce { - fn new() -> (Self, std::sync::mpsc::Receiver<()>) { - let (signal, rx) = std::sync::mpsc::channel(); - let announce = Self { - buf: Vec::new(), - signal: Some(signal), - }; - (announce, rx) - } - - fn text(self) -> String { - String::from_utf8(self.buf).expect("utf8") - } - } - - impl Write for Announce { - fn write(&mut self, data: &[u8]) -> std::io::Result { - self.buf.extend_from_slice(data); - if let Some(signal) = self.signal.take() { - let _ = signal.send(()); - } - Ok(data.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(30); - - /// Waits for the waiter's announcement; panics rather than hanging when it never comes. - fn announced(rx: &std::sync::mpsc::Receiver<()>) { - rx.recv_timeout(ANNOUNCE_TIMEOUT) - .expect("waiter never announced a wait"); - } - - /// Waits for the announcement, then always drops `held`, so a missing wait fails the caller's - /// assertions instead of hanging `hold()` forever. Returns whether the wait was announced. - fn released_after_wait(rx: &std::sync::mpsc::Receiver<()>, held: T) -> bool { - let seen = rx.recv_timeout(ANNOUNCE_TIMEOUT).is_ok(); - drop(held); - seen - } - - /// Like `take_gate` on a thread; the receiver fires once the thread is waiting in line. - fn spawn_take( - gate: Gate, - ) -> ( - std::thread::JoinHandle<(Slot, String, String)>, - std::sync::mpsc::Receiver<()>, - ) { - let (mut err, rx) = Announce::new(); - let waiter = std::thread::spawn(move || { - let mut events = Vec::new(); - let slot = gate - .take(Command::Test, &mut err, &mut events) - .expect("take") - .expect("slot"); - (slot, err.text(), String::from_utf8(events).expect("utf8")) - }); - (waiter, rx) - } - - fn take_gate(gate: &Gate) -> (Slot, String, String) { - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = gate - .take(Command::Test, &mut err, &mut events) - .expect("take") - .expect("slot"); - ( - slot, - String::from_utf8(err).expect("utf8"), - String::from_utf8(events).expect("utf8"), - ) - } - - #[test] - fn a_rules_dx_run_keeps_consumers_off_the_reserve() { - let scratch = dx_test_scratch::scratch("dx-slots-reserve-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 1, "r".to_owned(), false).reserving(2); - let (slot, err, _) = take_gate(&gate); - assert_eq!(slot.index, 0); - assert_eq!(err, ""); - assert!(try_take(&dir, 2, "c").expect("try").is_none()); - drop(slot); - assert!(slot_settles_free(&dir, 2)); - } - - fn count(n: usize) -> BuildSlots { - BuildSlots::Count(std::num::NonZeroUsize::new(n).expect("n")) - } - - #[test] - fn auto_sizing_matches_memory_sizing() { - for (memory, reserved) in [ - (64u64 << 30, 0u64), - (64 << 30, 6 << 30), - (2 << 30, 0), - (19 << 30, 6 << 30), - ] { - let count = slot_count(memory, reserved); - assert_eq!( - sizing(BuildSlots::Auto, memory, reserved), - Some(Sizing { - count, - pool: slot_count(memory, 0), - extras: remote_slot_count(memory, reserved).saturating_sub(count), - }), - "{memory} {reserved}" - ); - } - } - - #[test] - fn count_sizing_keeps_the_reserve_width_above_n() { - let memory = 30u64 << 30; - assert_eq!( - sizing(count(2), memory, 0), - Some(Sizing { - count: 2, - pool: 2, - extras: 0 - }) - ); - let width = slot_count(memory, 0) - slot_count(memory, 6 << 30); - assert_eq!(width, 2); - assert_eq!( - sizing(count(2), memory, 6 << 30), - Some(Sizing { - count: 2, - pool: 2 + width, - extras: 0 - }) - ); - assert_eq!( - sizing(count(40), memory, 0), - Some(Sizing { - count: 40, - pool: 40, - extras: 0 - }), - "an explicit count is not clamped to {MAX_SLOTS}" - ); - assert_eq!(sizing(BuildSlots::Off, memory, 0), None); - } - - #[test] - fn the_default_setting_is_auto_only_inside_a_worktree_slot() { - let plain = dx_test_scratch::scratch("dx-slots-setting-"); - assert_eq!(effective_setting(None, plain.path()), BuildSlots::Off); - assert_eq!(effective_setting(Some(count(2)), plain.path()), count(2)); - let pool = dx_test_scratch::scratch("dx-slots-setting-pool-"); - let slot = pool.path().join("pool/1"); - std::fs::create_dir_all(&slot).expect("slot"); - std::fs::create_dir_all(pool.path().join("pool/.dx")).expect("meta"); - let record = dx_worktree::Record { - slot: 1, - held: false, - branch: None, - last_branch: None, - session: None, - agent: None, - pid: None, - pid_start: None, - boot_id: None, - gone: false, - stopped_at: None, - unclaimed: false, - broken: false, - leased_at: 0, - last_used: 0, - lock_digest: None, - }; - std::fs::write( - pool.path().join("pool/.dx/1.json"), - serde_json::to_vec(&record).expect("json"), - ) - .expect("record"); - assert_eq!(effective_setting(None, &slot), BuildSlots::Auto); - assert_eq!( - effective_setting(Some(BuildSlots::Off), &slot), - BuildSlots::Off - ); - } - - #[test] - fn off_and_nested_runs_build_no_gate() { - let scratch = dx_test_scratch::scratch("dx-slots-off-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let get = |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - assert!(Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Off).is_none()); - let gate = Gate::from_env(Path::new("/ws"), &get, false, &[], count(3)).expect("gate"); - assert_eq!((gate.count, gate.pool, gate.extras), (3, 3, 0)); - let nested = |name: &str| match name { - "XDG_CACHE_HOME" => Some(cache.clone()), - SLOT_ENV_VAR => Some("1".to_owned()), - _ => None, - }; - for setting in [BuildSlots::Auto, BuildSlots::Off, count(1), count(40)] { - assert!( - Gate::from_env(Path::new("/ws"), &nested, false, &[], setting).is_none(), - "a child under a held slot ignores {setting}" - ); - } - } - - #[test] - fn a_count_gate_waits_on_the_take_past_n() { - let scratch = dx_test_scratch::scratch("dx-slots-count-wait-"); - let dir = scratch.path().join("slots"); - let size = sizing(count(2), 64 << 30, 0).expect("sizing"); - let gate = Gate::new(dir.clone(), size.count, "fill".to_owned(), false) - .reserving(size.pool) - .extra(size.extras, false); - let (first, _, _) = take_gate(&gate); - let (second, _, _) = take_gate(&gate); - assert_eq!((first.index, second.index), (0, 1)); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - drop(first); - let (third, err, _) = waiter.join().expect("join"); - assert_eq!(third.index, 0); - assert!( - err.starts_with("dx: waiting for a build slot (held by fill"), - "{err}" - ); - drop(second); - } - - #[test] - fn rules_dx_runs_share_the_reserve() { - let scratch = dx_test_scratch::scratch("dx-slots-share-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 2, "r".to_owned(), false).reserving(3); - let (first, first_err, _) = take_gate(&gate); - let (second, second_err, _) = take_gate(&gate); - assert_eq!((first.index, second.index), (0, 1)); - assert_eq!((first_err.as_str(), second_err.as_str()), ("", "")); - assert!(try_take(&dir, 3, "c").expect("try").is_none()); - } - - #[test] - fn a_rules_dx_run_waits_for_a_consumer_on_the_reserve() { - let scratch = dx_test_scratch::scratch("dx-slots-reserve-wait-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); - let consumer = try_take(&dir, 2, "consumer") - .expect("try") - .expect("consumer"); - assert_eq!(consumer.index, 1); - drop(fill); - let gate = Gate::new(dir.clone(), 1, "r".to_owned(), true).reserving(2); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - drop(consumer); - let (slot, err, events) = waiter.join().expect("join"); - assert_eq!(slot.index, 0); - assert_eq!(err, format!("dx: {}\n", wait_message("consumer"))); - assert_eq!( - events.matches("waiting_for_build_slot").count(), - 1, - "{events}" - ); - } - - #[test] - fn a_want_round_trips_and_garbage_wants_everything() { - let want = Want { - base: 0..3, - reserve: 3..5, - extras: 5..7, - remote: true, - }; - assert_eq!(Want::parse(&want.render()), Some(want)); - for garbage in [ - "", - "base=0..1", - "base=x", - "base=0..1 reserve=1..1 extras=1..1 remote=0 bogus=1", - ] { - assert_eq!(Want::parse(garbage), None, "{garbage}"); - } - let scratch = dx_test_scratch::scratch("dx-slots-want-garbage-"); - assert_eq!( - Want::read(&scratch.path().join("missing.want")), - Want::everything() - ); - } - - #[test] - fn a_dropped_ticket_removes_its_want_and_the_sweep_removes_orphans() { - let scratch = dx_test_scratch::scratch("dx-slots-want-drop-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(queue_dir(&dir)).expect("queue"); - let want = Want::of( - &Layout { - count: 1, - reserve: 1..2, - extras: 2..2, - }, - false, - ); - let ticket = enqueue_wanting(&dir, "", &want).expect("ticket"); - let path = want_path(&ticket.path); - assert_eq!(Want::read(&path), want); - drop(ticket); - assert!(!path.exists()); - let orphan = queue_dir(&dir).join("ticket-00000000000000000099.want"); - std::fs::write(&orphan, "x").expect("orphan"); - drop(enqueue(&dir, "").expect("ticket")); - assert!(!orphan.exists()); - } - - #[test] - fn a_waiters_label_is_kept_from_its_sidecar_and_swept_with_the_ticket() { - let scratch = dx_test_scratch::scratch("dx-slots-label-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(queue_dir(&dir)).expect("queue"); - let ticket = enqueue(&dir, "/ws/waiter").expect("ticket"); - let label = label_path(&ticket.path); - std::fs::write(&label, "/ws/sidecar").expect("sidecar"); - let kept = kept_workspaces(&dir, 0..0, "/ws/own"); - assert!(kept.contains(&PathBuf::from("/ws/sidecar"))); - assert!(label.exists()); - drop(ticket); - assert!(!label.exists()); - let orphan = queue_dir(&dir).join("ticket-00000000000000000099.label"); - std::fs::write(&orphan, "x").expect("orphan"); - drop(enqueue(&dir, "").expect("ticket")); - assert!(!orphan.exists()); - } - - #[test] - fn a_future_mtime_is_not_recent_and_not_a_live_waiter() { - let scratch = dx_test_scratch::scratch("dx-slots-future-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - let future = SystemTime::now() + Duration::from_secs(3600); - let set = |path: &Path| { - let file = File::options().write(true).open(path).expect("open"); - file.set_modified(future).expect("set mtime"); - }; - std::fs::write(extra_mark(&dir), "").expect("mark"); - assert!(extra_recent(&dir)); - set(&extra_mark(&dir)); - assert!(!extra_recent(&dir)); - let ticket = dir.join("ticket"); - std::fs::write(&ticket, "").expect("ticket"); - assert!(!waiting(&ticket)); - set(&ticket); - assert!(!waiting(&ticket)); - } - - #[test] - fn an_unwritable_queue_dir_warns_and_the_run_still_gets_a_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-queue-warn-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - std::fs::write(queue_dir(&dir), "not a directory").expect("block queue"); - let layout = Layout { - count: 1, - reserve: 1..1, - extras: 1..1, - }; - let mut skipped = Vec::new(); - let slot = acquire_reserving( - &dir, - &layout, - Admission::none(), - "r", - &mut |_, _| {}, - &mut || {}, - &mut |path, _| skipped.push(path.to_path_buf()), - ) - .expect("slot"); - assert_eq!(slot.index, 0); - assert_eq!(skipped, vec![queue_dir(&dir)]); - } - - #[test] - fn an_unwritable_queue_warns_once_through_the_gate() { - let scratch = dx_test_scratch::scratch("dx-slots-queue-gate-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - std::fs::write(queue_dir(&dir), "").expect("blocker"); - let gate = Gate::new(dir.clone(), 2, "a".to_owned(), false); - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = gate.take(Command::Test, &mut err, &mut events); - assert!(matches!(slot, Ok(Some(_)))); - let text = String::from_utf8(err).expect("utf8"); - assert_eq!(text.lines().count(), 1, "{text}"); - assert!( - text.contains(&queue_dir(&dir).display().to_string()), - "{text}" - ); - } - - #[test] - fn a_consumer_passes_a_rules_dx_head_blocked_on_its_reserve() { - let scratch = dx_test_scratch::scratch("dx-slots-pass-head-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); - let consumer = try_take(&dir, 2, "consumer") - .expect("try") - .expect("consumer"); - assert_eq!(consumer.index, 1); - drop(fill); - let (sender, receiver) = std::sync::mpsc::channel(); - let head_dir = dir.clone(); - let head = std::thread::spawn(move || { - let layout = Layout { - count: 1, - reserve: 1..2, - extras: 2..2, - }; - acquire_reserving( - &head_dir, - &layout, - Admission::none(), - "r", - &mut |holder, _| { - sender.send(holder.to_owned()).expect("send"); - }, - &mut || {}, - &mut |_, _| {}, - ) - .map(|slot| slot.index) - }); - receiver - .recv_timeout(Duration::from_secs(30)) - .expect("head waits"); - let behind = acquire(&dir, 2, "behind", &mut |_| {}).expect("behind passes"); - assert_eq!(behind.index, 0); - drop(behind); - drop(consumer); - assert_eq!(head.join().expect("join").expect("head"), 0); - } - - #[test] - fn a_queued_consumer_does_not_take_the_reserve_behind_a_rules_dx_run() { - let scratch = dx_test_scratch::scratch("dx-slots-reserve-line-"); - let dir = scratch.path().join("slots"); - let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); - let consumer = try_take(&dir, 2, "consumer") - .expect("try") - .expect("consumer"); - drop(fill); - let (sender, receiver) = std::sync::mpsc::channel(); - let rules_dx_dir = dir.clone(); - let rules_dx = std::thread::spawn(move || { - let layout = Layout { - count: 1, - reserve: 1..2, - extras: 2..2, - }; - acquire_reserving( - &rules_dx_dir, - &layout, - Admission::none(), - "r", - &mut |holder, _| { - sender.send(holder.to_owned()).expect("send"); - }, - &mut || {}, - &mut |_, _| {}, - ) - .expect("slot") - }); - let notice = receiver - .recv_timeout(Duration::from_secs(30)) - .expect("notice"); - assert_eq!(notice, "consumer"); - drop(consumer); - let held = rules_dx.join().expect("join"); - assert_eq!(held.index, 0); - let (sender, receiver) = std::sync::mpsc::channel(); - let late_dir = dir.clone(); - let late = std::thread::spawn(move || { - acquire(&late_dir, 2, "late", &mut |holder| { - sender.send(holder.to_owned()).expect("send"); - }) - .expect("slot") - .index - }); - let notice = receiver - .recv_timeout(Duration::from_secs(30)) - .expect("notice"); - assert_eq!(notice, "r"); - std::thread::sleep(Duration::from_millis(600)); - assert!( - !late.is_finished(), - "a consumer must not take a reserve slot" - ); - drop(held); - assert!(late.join().expect("join") < 2); - } - - #[test] - fn an_unopenable_slot_file_is_skipped() { - let scratch = dx_test_scratch::scratch("dx-slots-skip-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); - let slot = acquire(&dir, 2, "a", &mut |_| panic!("must not wait")).expect("slot"); - assert_eq!(slot.index, 1); - } - - #[cfg(unix)] - #[test] - fn read_only_slot_file_still_counts() { - use std::os::unix::fs::PermissionsExt; - let scratch = dx_test_scratch::scratch("dx-slots-readonly-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - let path = slot_path(&dir, 0); - std::fs::write(&path, b"").expect("slot file"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).expect("chmod"); - let slot = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("slot"); - assert_eq!(slot.index, 0); - assert!(try_take(&dir, 1, "b").expect("try").is_none()); - drop(slot); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); - } - - #[cfg(unix)] - #[test] - fn a_read_only_reserve_slot_still_reserves() { - use std::os::unix::fs::PermissionsExt; - let scratch = dx_test_scratch::scratch("dx-slots-readonly-reserve-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - let path = slot_path(&dir, 1); - std::fs::write(&path, b"").expect("slot file"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).expect("chmod"); - let gate = Gate::new(dir.clone(), 1, "r".to_owned(), false).reserving(2); - let (slot, err, _) = take_gate(&gate); - assert_eq!((slot.index, err.as_str()), (0, "")); - assert!(try_take(&dir, 2, "c").expect("try").is_none()); - drop(slot); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); - } - - #[test] - fn a_skipped_slot_warns_once() { - for json in [true, false] { - let scratch = dx_test_scratch::scratch("dx-slots-warn-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); - let gate = Gate::new(dir, 2, "a".to_owned(), json); - let mut err = Vec::new(); - let mut events = Vec::new(); - for _ in 0..2 { - let slot = gate.clone().take(Command::Test, &mut err, &mut events); - assert!(matches!(slot, Ok(Some(ref slot)) if slot.index == 1)); - } - let text = String::from_utf8(err).expect("utf8"); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 1, "{text}"); - assert!(lines[0].starts_with("dx: skipped build slot "), "{text}"); - assert!(lines[0].contains("slot-0.lock"), "{text}"); - let events = String::from_utf8(events).expect("utf8"); - if json { - assert_eq!(events.lines().count(), 1, "{events}"); - assert!(events.contains("\"event\":\"notice\"")); - assert!(events.contains("build_slot_skipped")); - assert!(events.contains("\"severity\":\"warning\"")); - } else { - assert!(events.is_empty(), "{events}"); - } - } - } - - #[test] - fn no_openable_slot_file_is_an_error() { - let scratch = dx_test_scratch::scratch("dx-slots-none-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); - assert!(try_take(&dir, 1, "a").is_err()); - } - - #[test] - fn a_bad_slot_and_busy_slots_still_wait() { - let scratch = dx_test_scratch::scratch("dx-slots-partial-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); - let _held = acquire(&dir, 2, "a", &mut |_| {}).expect("held"); - assert!(try_take(&dir, 2, "b").expect("try").is_none()); - } - - #[test] - fn a_crashed_holder_frees_its_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-crash-"); - let dir = scratch.path().join("slots"); - let mut child = spawn_holder(&dir); - assert!(try_take(&dir, 1, "me").expect("try").is_none()); - child.kill().expect("kill"); - child.wait().expect("wait"); - let slot = acquire(&dir, 1, "me", &mut |_| {}).expect("slot after crash"); - assert_eq!(slot.index, 0); - } - - #[test] - fn a_nested_call_takes_no_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-nested-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let nested = |name: &str| match name { - "XDG_CACHE_HOME" => Some(cache.clone()), - SLOT_ENV_VAR => Some("1".to_owned()), - _ => None, - }; - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = hold( - Command::Build, - Path::new("/ws"), - &nested, - &mut err, - false, - &mut events, - ); - assert!(matches!(slot, Ok(None))); - assert!(!scratch.path().join("dx").exists()); - } - - #[test] - fn a_dx_call_inside_a_bazel_test_takes_no_slot_when_all_are_full() { - let scratch = dx_test_scratch::scratch("dx-slots-bazel-test-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let dir = scratch.path().join("dx").join("slots"); - let count = slot_count(available_memory().expect("memory"), 0); - let _held: Vec = (0..count) - .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("fill")) - .collect(); - let inside_test = |name: &str| match name { - "XDG_CACHE_HOME" => Some(cache.clone()), - BAZEL_TEST_ENV_VAR => Some("1".to_owned()), - _ => None, - }; - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = hold( - Command::Test, - Path::new("/ws"), - &inside_test, - &mut err, - true, - &mut events, - ); - assert!(matches!(slot, Ok(None))); - assert!(err.is_empty() && events.is_empty()); - } - - fn busy(count: usize) -> String { - let more = (1..count) - .last() - .map_or(String::new(), |n| format!(" and {n} more")); - format!("tree-0{more}") - } - - #[test] - fn waiting_prints_one_line_and_one_json_notice() { - let scratch = dx_test_scratch::scratch("dx-slots-notice-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let dir = scratch.path().join("dx").join("slots"); - let count = slot_count(available_memory().expect("memory"), 0); - let held: Vec = (0..count) - .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("fill")) - .collect(); - let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - let (mut err, rx) = Announce::new(); - let releaser = std::thread::spawn(move || released_after_wait(&rx, held)); - let mut events = Vec::new(); - let slot = hold( - Command::Test, - Path::new("/ws"), - &get, - &mut err, - true, - &mut events, - ); - assert!( - releaser.join().expect("join"), - "waiter never announced a wait" - ); - assert!(matches!(slot, Ok(Some(_)))); - let text = err.text(); - assert_eq!(text.lines().count(), 1); - assert!(text.contains(&wait_message(&busy(count))), "{text}"); - let json = String::from_utf8(events).expect("utf8"); - assert_eq!(json.lines().count(), 1); - assert!(json.contains("\"event\":\"notice\"")); - assert!(json.contains("waiting_for_build_slot")); - } - - #[test] - fn unopenable_slots_print_one_disabled_line_and_one_json_notice() { - let scratch = dx_test_scratch::scratch("dx-slots-disabled-"); - let cache = scratch.path().to_string_lossy().into_owned(); - std::fs::create_dir_all(scratch.path().join("dx")).expect("dx dir"); - std::fs::write(scratch.path().join("dx").join("slots"), b"").expect("blocker"); - let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - for json in [true, false] { - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = hold( - Command::Test, - Path::new("/ws"), - &get, - &mut err, - json, - &mut events, - ); - assert!(matches!(slot, Ok(None))); - let text = String::from_utf8(err).expect("utf8"); - assert_eq!(text.lines().count(), 1); - assert!(text.starts_with("dx: build slots disabled: "), "{text}"); - let lines = String::from_utf8(events).expect("utf8"); - if json { - assert_eq!(lines.lines().count(), 1); - assert!(lines.contains("\"event\":\"notice\"")); - assert!(lines.contains("build_slots_disabled")); - assert!(lines.contains("\"severity\":\"warning\"")); - } else { - assert!(lines.is_empty()); - } - } - } - - #[test] - fn update_holds_no_process_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-update-"); - let cache = scratch.path().to_string_lossy().into_owned(); - let dir = scratch.path().join("dx").join("slots"); - let count = slot_count(available_memory().expect("memory"), 0); - let held: Vec = (0..count) - .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("fill")) - .collect(); - let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - let mut err = Vec::new(); - let mut events = Vec::new(); - let slot = hold( - Command::Update, - Path::new("/ws"), - &get, - &mut err, - true, - &mut events, - ); - assert!(matches!(slot, Ok(None))); - assert!(err.is_empty(), "{}", String::from_utf8_lossy(&err)); - assert!(events.is_empty(), "{}", String::from_utf8_lossy(&events)); - drop(held); - } - - #[test] - fn a_waiter_polls_while_it_waits_and_stops_once_it_takes_the_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-poll-"); - let dir = scratch.path().join("slots"); - let first = acquire(&dir, 1, "tree-a", &mut |_| {}).expect("first"); - let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen = polls.clone(); - let waiter_dir = dir.clone(); - let waiter = std::thread::spawn(move || { - acquire_with( - &waiter_dir, - 1, - "tree-b", - &mut |_, _| {}, - &mut || { - seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - }, - &mut |_, _| {}, - ) - .expect("second") - }); - let start = Instant::now(); - while polls.load(std::sync::atomic::Ordering::SeqCst) < 2 { - assert!( - start.elapsed() < Duration::from_secs(30), - "waiter never polled" - ); - std::thread::sleep(Duration::from_millis(25)); - } - drop(first); - let slot = waiter.join().expect("join"); - let after = polls.load(std::sync::atomic::Ordering::SeqCst); - assert!(after >= 2); - std::thread::sleep(Duration::from_millis(500)); - assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), after); - drop(slot); - } - - type Reaps = Arc, u64)>>>; - - fn recording(gate: Gate, stopped: usize) -> (Gate, Reaps) { - let calls: Reaps = Arc::default(); - let seen = Arc::clone(&calls); - let gate = gate.reaping(move |keep, target| { - seen.lock().expect("reaps").push((keep.to_vec(), target)); - stopped - }); - (gate, calls) - } - - /// Makes `gate` wait behind a holder, then frees the slot; returns the wait text and JSON. - fn wait_behind_holder(dir: &Path, gate: Gate) -> (String, String) { - let holder = take_gate(&Gate::new( - dir.to_path_buf(), - 1, - "/w/holder".to_owned(), - false, - )) - .0; - let (waiter, rx) = spawn_take(gate); - announced(&rx); - drop(holder); - let (_slot, err, events) = waiter.join().expect("join"); - (err, events) - } - - fn wait_notice(events: &str) -> serde_json::Value { - let notices: Vec = events - .lines() - .map(|line| serde_json::from_str(line).expect("json")) - .collect(); - assert_eq!(notices.len(), 1, "{events}"); - assert_eq!(notices[0]["code"], "waiting_for_build_slot"); - notices[0].clone() - } - - fn busy_gate(dir: &Path, wait: Duration) -> (Gate, Slot) { - let holder = take_gate(&Gate::new( - dir.to_path_buf(), - 1, - "/w/holder".to_owned(), - false, - )) - .0; - let start = Instant::now(); - let ticks = std::sync::atomic::AtomicU64::new(0); - let gate = Gate::new(dir.to_path_buf(), 1, "/w/me".to_owned(), true) - .with_clock(move || { - start + Duration::from_secs(ticks.fetch_add(1, std::sync::atomic::Ordering::SeqCst)) - }) - .waiting_at_most(Some(wait)); - (gate, holder) - } - - #[test] - fn a_zero_wait_fails_at_once_naming_the_holder() { - let scratch = dx_test_scratch::scratch("dx-slots-busy-"); - let dir = scratch.path().join("slots"); - let (gate, _holder) = busy_gate(&dir, Duration::ZERO); - let (mut err, mut events) = (Vec::new(), Vec::new()); - let stop = gate - .take(Command::Test, &mut err, &mut events) - .err() - .expect("busy"); - assert_eq!(stop, Stop::Busy("/w/holder".to_owned())); - assert!(err.is_empty() && events.is_empty()); - } - - #[test] - fn a_wait_gives_up_once_the_clock_passes_the_limit() { - let scratch = dx_test_scratch::scratch("dx-slots-deadline-"); - let dir = scratch.path().join("slots"); - let (gate, _holder) = busy_gate(&dir, Duration::from_secs(3)); - let (mut err, mut events) = (Vec::new(), Vec::new()); - let stop = gate - .take(Command::Test, &mut err, &mut events) - .err() - .expect("busy"); - assert_eq!(stop, Stop::Busy("/w/holder".to_owned())); - assert!( - String::from_utf8(err) - .expect("utf8") - .contains("waiting for a build slot") - ); - } - - #[test] - fn a_free_slot_is_taken_even_with_a_zero_wait() { - let scratch = dx_test_scratch::scratch("dx-slots-free-"); - let dir = scratch.path().join("slots"); - let gate = - Gate::new(dir, 1, "/w/me".to_owned(), false).waiting_at_most(Some(Duration::ZERO)); - assert!(take_gate(&gate).0.index == 0); - } - - #[test] - fn a_wait_reaps_once_keeping_holders_waiters_and_itself() { - let scratch = dx_test_scratch::scratch("dx-slots-reap-"); - let dir = scratch.path().join("slots"); - let holder = take_gate(&Gate::new(dir.clone(), 1, "/w/holder".to_owned(), false)).0; - let queued = enqueue(&dir, "/w/waiter").expect("queued"); - let (gate, calls) = recording(Gate::new(dir.clone(), 1, "/w/me".to_owned(), true), 2); - let (waiter, rx) = spawn_take(gate); - announced(&rx); - drop(queued); - drop(holder); - let (_slot, err, events) = waiter.join().expect("join"); - let calls = calls.lock().expect("reaps").clone(); - assert_eq!(calls.len(), 1); - let (keep, target) = &calls[0]; - for workspace in ["/w/me", "/w/holder", "/w/waiter"] { - assert!(keep.contains(&PathBuf::from(workspace)), "{keep:?}"); - } - assert_eq!(*target, BYTES_PER_SLOT); - assert_eq!( - err.trim(), - format!( - "dx: {}; stopped 2 idle Bazel servers", - wait_message("/w/holder, 1 ahead in line") - ) - ); - let notice = wait_notice(&events); - assert_eq!(notice["reaped_servers"], 2); - assert!( - notice["message"] - .as_str() - .is_some_and(|text| text.ends_with("; stopped 2 idle Bazel servers")) - ); - } - - #[test] - fn a_wait_that_stops_nothing_keeps_the_plain_line_and_reports_zero() { - let scratch = dx_test_scratch::scratch("dx-slots-reap-none-"); - let dir = scratch.path().join("slots"); - let (gate, calls) = recording(Gate::new(dir.clone(), 1, "/w/me".to_owned(), true), 0); - let (err, events) = wait_behind_holder(&dir, gate); - assert_eq!(calls.lock().expect("reaps").len(), 1); - assert_eq!(err.trim(), format!("dx: {}", wait_message("/w/holder"))); - assert_eq!(wait_notice(&events)["reaped_servers"], 0); - } - - #[test] - fn a_gate_without_a_reaper_reports_no_reaped_servers() { - let scratch = dx_test_scratch::scratch("dx-slots-no-reaper-"); - let dir = scratch.path().join("slots"); - let gate = Gate::new(dir.clone(), 1, "/w/me".to_owned(), true); - let (err, events) = wait_behind_holder(&dir, gate); - assert_eq!(err.trim(), format!("dx: {}", wait_message("/w/holder"))); - assert!(wait_notice(&events).get("reaped_servers").is_none()); - } - - #[test] - fn another_reaping_waiter_skips_the_reap() { - let scratch = dx_test_scratch::scratch("dx-slots-reap-busy-"); - let dir = scratch.path().join("slots"); - std::fs::create_dir_all(&dir).expect("dir"); - let busy = dx_atomic_fs::lease::try_lock_file(&dir.join("reap.lock"), true) - .expect("lock") - .expect("free"); - let (gate, calls) = recording(Gate::new(dir.clone(), 1, "/w/me".to_owned(), true), 3); - let (err, events) = wait_behind_holder(&dir, gate); - drop(busy); - assert!(calls.lock().expect("reaps").is_empty()); - assert_eq!(err.trim(), format!("dx: {}", wait_message("/w/holder"))); - assert_eq!(wait_notice(&events)["reaped_servers"], 0); - } - - #[test] - fn a_free_slot_never_reaps() { - let scratch = dx_test_scratch::scratch("dx-slots-reap-free-"); - let (gate, calls) = recording( - Gate::new(scratch.path().join("slots"), 1, "/w/me".to_owned(), true), - 1, - ); - let (_slot, err, events) = take_gate(&gate); - assert!(calls.lock().expect("reaps").is_empty()); - assert!(err.is_empty() && events.is_empty()); - } - - #[test] - fn a_remote_gate_reaps_one_remote_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-reap-remote-"); - let dir = scratch.path().join("slots"); - let (gate, calls) = recording( - Gate::new(dir.clone(), 1, "/w/me".to_owned(), false).extra(0, true), - 0, - ); - wait_behind_holder(&dir, gate); - assert_eq!(calls.lock().expect("reaps")[0].1, BYTES_PER_REMOTE_SLOT); - } - - #[test] - fn reaped_suffix_counts_servers() { - assert_eq!(reaped_suffix(0), ""); - assert_eq!(reaped_suffix(1), "; stopped 1 idle Bazel server"); - assert_eq!(reaped_suffix(3), "; stopped 3 idle Bazel servers"); - } - - #[test] - fn a_free_slot_never_polls() { - let scratch = dx_test_scratch::scratch("dx-slots-nopoll-"); - let dir = scratch.path().join("slots"); - let mut polls = 0; - let slot = acquire_with( - &dir, - 1, - "tree", - &mut |_, _| {}, - &mut || polls += 1, - &mut |_, _| {}, - ) - .expect("slot"); - assert_eq!(slot.index, 0); - assert_eq!(polls, 0); - } - - #[test] - fn ping_interval_is_a_third_of_the_idle_limit_within_bounds() { - assert_eq!(ping_interval(Some(90)), Some(Duration::from_secs(30))); - assert_eq!(ping_interval(Some(300)), Some(Duration::from_secs(60))); - assert_eq!(ping_interval(Some(900)), Some(Duration::from_secs(60))); - assert_eq!(ping_interval(None), Some(Duration::from_secs(60))); - assert_eq!(ping_interval(Some(2)), Some(Duration::from_secs(1))); - assert_eq!(ping_interval(Some(0)), None); - } - - #[test] - fn idle_limit_is_read_from_the_server_cmdline() { - let cmdline = "bazel(ws)\0-jar\0/i/A-server.jar\0--max_idle_secs=90\0--output_base=/o\0"; - assert_eq!(idle_limit(cmdline), Some(90)); - assert_eq!(idle_limit("bazel(ws)\0--output_base=/o\0"), None); - assert_eq!(idle_limit("--max_idle_secs=x\0"), None); - } - - #[test] - fn startup_options_come_from_the_dx_command_line() { - let words = |list: &[&str]| list.iter().map(OsString::from).collect::>(); - assert_eq!( - own_startup_options(&words(&["test", "//..."])), - Some(Vec::new()) - ); - assert_eq!( - own_startup_options(&words(&[ - "test", - "--bazel-startup-option=--output_base=/o", - "//..." - ])), - Some(vec!["--output_base=/o".to_owned()]) - ); - assert_eq!(own_startup_options(&words(&["slots::"])), None); - } - - const BASE: &str = "0123456789abcdef0123456789abcdef"; - - fn server(pid: u32, workspace: &str, root: &str, base: &str) -> Server { - Server { - pid, - rss_bytes: 0, - workspace: Some(PathBuf::from(workspace)), - output_base: Some(PathBuf::from(base)), - output_user_root: Some(PathBuf::from(root)), - } - } - - #[test] - fn own_server_is_the_one_this_workspace_reaches() { - let roots = vec![PathBuf::from("/c/_bazel_u")]; - let base = format!("/c/_bazel_u/{BASE}"); - let servers = vec![ - server( - 1, - "/other", - "/c/_bazel_u", - "/c/_bazel_u/ffffffffffffffffffffffffffffffff", - ), - server(2, "/ws", "/c/_bazel_u", &base), - ]; - let pid = |root: Option<&str>, base: Option<&str>| { - own_server( - &servers, - Path::new("/ws"), - root.map(Path::new), - base.map(Path::new), - &roots, - ) - .map(|found| found.pid) - }; - assert_eq!(pid(None, None), Some(2)); - assert_eq!(pid(None, Some(&base)), Some(2)); - assert_eq!(pid(None, Some("/elsewhere")), None); - assert_eq!(pid(Some("/elsewhere"), None), None); - assert_eq!( - own_server(&servers, Path::new("/nope"), None, None, &roots).map(|found| found.pid), - None - ); - } - - #[test] - fn own_server_skips_servers_a_plain_call_would_not_reach() { - let roots = vec![PathBuf::from("/c/_bazel_u")]; - let custom = vec![server(3, "/ws", "/c/_bazel_u", "/c/_bazel_u/custom")]; - assert!(own_server(&custom, Path::new("/ws"), None, None, &roots).is_none()); - let other_root = vec![server(4, "/ws", "/r", &format!("/r/{BASE}"))]; - assert!(own_server(&other_root, Path::new("/ws"), None, None, &roots).is_none()); - assert_eq!( - own_server( - &other_root, - Path::new("/ws"), - Some(Path::new("/r")), - None, - &roots - ) - .map(|found| found.pid), - Some(4) - ); - let two = vec![ - server(5, "/ws", "/c/_bazel_u", &format!("/c/_bazel_u/{BASE}")), - server( - 6, - "/ws", - "/c/_bazel_u", - "/c/_bazel_u/fedcba9876543210fedcba9876543210", - ), - ]; - assert!(own_server(&two, Path::new("/ws"), None, None, &roots).is_none()); - } - - #[test] - fn own_server_follows_an_rc_root_and_base() { - let roots = vec![PathBuf::from("/c/_bazel_u")]; - let servers = vec![server(7, "/ws", "/r", "/r/custom")]; - let pid = |root: Option<&str>, base: Option<&str>| { - own_server( - &servers, - Path::new("/ws"), - root.map(Path::new), - base.map(Path::new), - &roots, - ) - .map(|found| found.pid) - }; - assert_eq!(pid(Some("/r"), Some("/r/custom")), Some(7)); - assert_eq!(pid(None, Some("/r/custom")), None); - assert_eq!(pid(Some("/r"), None), None); - } - - #[test] - fn reap_roots_add_the_rc_root_to_the_defaults() { - let defaults = vec![PathBuf::from("/c/_bazel_u")]; - let none = dx_process::rc::StartupPaths::default(); - assert_eq!(reap_roots(&defaults, &none), defaults); - let paths = dx_process::rc::StartupPaths { - output_user_root: Some(PathBuf::from("/r")), - output_base: None, - }; - assert_eq!( - reap_roots(&defaults, &paths), - vec![PathBuf::from("/c/_bazel_u"), PathBuf::from("/r")] - ); - } - - #[derive(Default)] - struct FakeLog { - lookups: usize, - pings: usize, - stopped: usize, - running: bool, - dead: bool, - } - - struct FakeWarm { - server: Option<(u32, Option)>, - log: std::rc::Rc>, - } - - impl Warm for FakeWarm { - type Ping = (); - - fn own_server(&self) -> Option<(u32, Option)> { - self.log.borrow_mut().lookups += 1; - self.server - } - - fn alive(&self, _pid: u32) -> bool { - !self.log.borrow().dead - } - - fn ping(&self) -> Option<()> { - let mut log = self.log.borrow_mut(); - assert!(!log.running, "pings overlap"); - log.pings += 1; - log.running = true; - Some(()) - } - - fn finished(&self, _ping: &mut ()) -> bool { - !self.log.borrow().running - } - - fn stop(&self, _ping: ()) { - let mut log = self.log.borrow_mut(); - log.stopped += 1; - log.running = false; - } - } - - fn keeper( - server: Option<(u32, Option)>, - ) -> (KeepWarm, std::rc::Rc>) { - let log = std::rc::Rc::new(RefCell::new(FakeLog::default())); - let warm = FakeWarm { - server, - log: log.clone(), - }; - (KeepWarm::new(warm), log) - } - - #[test] - fn no_running_server_means_no_ping() { - let (mut keep, log) = keeper(None); - let start = Instant::now(); - for step in 0..10 { - keep.tick(start + Duration::from_secs(step * 100)); - } - drop(keep); - let log = log.borrow(); - assert_eq!(log.lookups, 1); - assert_eq!(log.pings, 0); - assert_eq!(log.stopped, 0); - } - - #[test] - fn a_server_that_never_idles_out_is_never_pinged() { - let (mut keep, log) = keeper(Some((7, Some(0)))); - let start = Instant::now(); - for step in 0..10 { - keep.tick(start + Duration::from_secs(step * 100)); - } - drop(keep); - let log = log.borrow(); - assert_eq!(log.lookups, 1); - assert_eq!(log.pings, 0); - assert_eq!(log.stopped, 0); - } - - #[test] - fn the_first_tick_pings_and_later_pings_wait_for_the_interval() { - let (mut keep, log) = keeper(Some((7, Some(90)))); - let start = Instant::now(); - keep.tick(start); - assert_eq!(log.borrow().pings, 1); - log.borrow_mut().running = false; - keep.tick(start + Duration::from_secs(29)); - assert_eq!(log.borrow().pings, 1); - keep.tick(start + Duration::from_secs(30)); - assert_eq!(log.borrow().pings, 2); - assert_eq!(log.borrow().lookups, 1); - } - - #[test] - fn a_running_ping_is_never_doubled() { - let (mut keep, log) = keeper(Some((7, Some(3)))); - let start = Instant::now(); - keep.tick(start); - keep.tick(start + Duration::from_secs(5)); - keep.tick(start + Duration::from_secs(10)); - assert_eq!(log.borrow().pings, 1); - log.borrow_mut().running = false; - keep.tick(start + Duration::from_secs(11)); - assert_eq!(log.borrow().pings, 2); - } - - #[test] - fn a_dead_server_stops_the_pings() { - let (mut keep, log) = keeper(Some((7, Some(3)))); - let start = Instant::now(); - keep.tick(start); - { - let mut log = log.borrow_mut(); - log.running = false; - log.dead = true; - } - keep.tick(start + Duration::from_secs(1)); - log.borrow_mut().dead = false; - keep.tick(start + Duration::from_secs(100)); - assert_eq!(log.borrow().pings, 1); - } - - #[test] - fn dropping_stops_a_running_ping() { - let (mut keep, log) = keeper(Some((7, None))); - keep.tick(Instant::now()); - assert!(log.borrow().running); - drop(keep); - assert_eq!(log.borrow().stopped, 1); - assert!(!log.borrow().running); - let (mut idle, idle_log) = keeper(Some((8, Some(900)))); - idle.tick(Instant::now()); - idle_log.borrow_mut().running = false; - idle.tick(Instant::now()); - drop(idle); - assert_eq!(idle_log.borrow().stopped, 0); - } - - /// Frees the holder's slot on its first ping, so a waiting `take` gets in on its next poll. - struct ReleasingWarm { - inner: FakeWarm, - holder: RefCell>, - } - - impl Warm for ReleasingWarm { - type Ping = (); - - fn own_server(&self) -> Option<(u32, Option)> { - self.inner.own_server() - } - - fn alive(&self, pid: u32) -> bool { - self.inner.alive(pid) - } - - fn ping(&self) -> Option<()> { - let ping = self.inner.ping(); - drop(self.holder.borrow_mut().take()); - ping - } - - fn finished(&self, ping: &mut ()) -> bool { - self.inner.finished(ping) - } - - fn stop(&self, ping: ()) { - self.inner.stop(ping); - } - } - - #[test] - fn a_waiting_take_keeps_its_server_warm_until_it_gets_a_slot() { - let scratch = dx_test_scratch::scratch("dx-slots-keepwarm-"); - let dir = scratch.path().to_path_buf(); - let (holder, _, _) = take_gate(&Gate::new(dir.clone(), 1, "/w/holder".to_owned(), false)); - let log = std::rc::Rc::new(RefCell::new(FakeLog::default())); - let warm = ReleasingWarm { - inner: FakeWarm { - server: Some((7, Some(90))), - log: log.clone(), - }, - holder: RefCell::new(Some(holder)), - }; - let waiter = Gate::new(dir, 1, "/w/waiter".to_owned(), false); - let mut err = Vec::new(); - let mut events = Vec::new(); - let got = waiter.take_warming(Command::Test, &mut err, &mut events, Some(warm)); - assert!(matches!(got, Ok(Some(_)))); - let log = log.borrow(); - assert_eq!((log.lookups, log.pings, log.stopped), (1, 1, 1)); - let err = String::from_utf8(err).expect("utf8"); - assert!(err.contains(&wait_message("/w/holder")), "{err}"); - } - - #[test] - fn a_free_slot_never_looks_for_a_server() { - let scratch = dx_test_scratch::scratch("dx-slots-nowarm-"); - let gate = Gate::new(scratch.path().to_path_buf(), 1, "/w/a".to_owned(), false); - let log = std::rc::Rc::new(RefCell::new(FakeLog::default())); - let warm = FakeWarm { - server: Some((7, Some(90))), - log: log.clone(), - }; - let got = gate.take_warming(Command::Test, &mut Vec::new(), &mut Vec::new(), Some(warm)); - assert!(matches!(got, Ok(Some(_)))); - let log = log.borrow(); - assert_eq!((log.lookups, log.pings), (0, 0)); - } - - #[test] - fn from_args_builds_warmth_from_argv_and_rc_roots() { - let scratch = dx_test_scratch::scratch("dx-slots-warmth-"); - let root = scratch.path().to_path_buf(); - let workspace = root.join("ws"); - std::fs::create_dir_all(&workspace).expect("workspace"); - let rc_root = root.join("r"); - std::fs::write( - workspace.join(".bazelrc"), - format!("startup --output_user_root={}\n", rc_root.display()), - ) - .expect("rc"); - let cache = root.join("cache").to_string_lossy().into_owned(); - let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); - let base = root.join("ob"); - let option = format!("--output_base={}", base.display()); - let args: Vec = vec![ - "test".into(), - format!("--bazel-startup-option={option}").into(), - "//...".into(), - ]; - let gate = Gate::from_args(&workspace, &get, false, &[], count(1), &args).expect("gate"); - let warm = gate.warm.expect("warmth from argv"); - assert_eq!(warm.startup_options, vec![option]); - assert_eq!(warm.output_user_root, Some(rc_root)); - assert_eq!(warm.output_base, Some(base)); - assert_eq!(warm.workspace, workspace); - - let args: Vec = vec!["slots::".into()]; - let gate = Gate::from_args(&workspace, &get, false, &[], count(1), &args).expect("gate"); - assert!( - gate.warm.is_none(), - "an argv that is not a dx command warms nothing" - ); - } - - #[test] - fn ping_argv_carries_startup_options_before_the_info_verb() { - let warmth = Warmth { - workspace: PathBuf::from("/ws"), - startup_options: vec!["--output_base=/ob".to_owned()], - output_user_root: None, - output_base: None, - default_roots: Vec::new(), - }; - let argv = warmth.ping_argv(); - let base = argv.iter().position(|arg| arg == "--output_base=/ob"); - let info = argv.iter().position(|arg| arg == "info"); - assert!(base.is_some() && base < info, "{argv:?}"); - assert_eq!( - argv[argv.len() - 3..], - ["--noblock_for_lock", "info", "server_pid"] - ); - } - - #[cfg(unix)] - #[test] - fn a_real_ping_child_is_reaped_on_stop() { - let warmth = Warmth { - workspace: std::env::temp_dir(), - startup_options: Vec::new(), - output_user_root: None, - output_base: None, - default_roots: Vec::new(), - }; - let mut child = Process::new("sleep") - .arg("30") - .stdout(Stdio::null()) - .spawn() - .expect("spawn"); - assert!(!warmth.finished(&mut child)); - let pid = i32::try_from(child.id()).expect("pid"); - warmth.stop(child); - assert_ne!(unsafe { libc::kill(pid, 0) }, 0); - } -} +#[path = "slots_tests.rs"] +mod tests; diff --git a/cli/plan/src/slots_tests.rs b/cli/plan/src/slots_tests.rs new file mode 100644 index 0000000000..571001af82 --- /dev/null +++ b/cli/plan/src/slots_tests.rs @@ -0,0 +1,2923 @@ +use super::*; +use std::process::{Command as Process, Stdio}; + +fn hold( + command: Command, + workspace: &Path, + env_get: &dyn Fn(&str) -> Option, + err: &mut dyn Write, + json: bool, + events: &mut dyn Write, +) -> Result, Stop> { + Gate::from_env(workspace, env_get, json, &[], BuildSlots::Auto) + .map(|gate| gate.with_pressure(Arc::new(Fixed(Some(false))))) + .map_or(Ok(None), |gate| gate.hold(command, err, events)) +} + +const ALONE_ENV: &str = "DX_SLOT_TEST_ALONE"; + +/// Runs `body` in a child test process so no other test touches the process share. +fn alone(test: &str, body: impl FnOnce()) { + if std::env::var_os(ALONE_ENV).is_some() { + body(); + return; + } + let status = Process::new(std::env::current_exe().expect("exe")) + .arg(format!("slots::tests::{test}")) + .arg("--exact") + .env(ALONE_ENV, "1") + .stdout(Stdio::null()) + .status() + .expect("spawn"); + assert!(status.success(), "{test} failed in its own process"); +} + +const HOLD_ENV: &str = "DX_SLOT_TEST_HOLD"; + +#[test] +fn slot_holder_helper() { + let Ok(dir) = std::env::var(HOLD_ENV) else { + return; + }; + let _slot = acquire(Path::new(&dir), 1, "helper-tree", &mut |_| {}).expect("slot"); + std::fs::write(Path::new(&dir).join("held"), b"1").expect("marker"); + std::thread::sleep(Duration::from_secs(120)); +} + +fn wait_for(path: &Path, what: &str) { + let start = std::time::Instant::now(); + while !path.exists() { + assert!(start.elapsed() < Duration::from_secs(30), "{what}"); + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn spawn_holder(dir: &Path) -> std::process::Child { + let child = Process::new(std::env::current_exe().expect("exe")) + .arg("slots::tests::slot_holder_helper") + .arg("--exact") + .env(HOLD_ENV, dir) + .stdout(Stdio::null()) + .spawn() + .expect("spawn"); + wait_for(&dir.join("held"), "holder never took the slot"); + child +} + +#[test] +fn count_follows_memory_with_bounds() { + assert_eq!(slot_count(0, 0), 1); + assert_eq!(slot_count(4 << 30, 0), 1); + assert_eq!(slot_count(8 << 30, 0), 2); + assert_eq!(slot_count(16 << 30, 0), 5); + assert_eq!(slot_count(16 << 30, 6 << 30), 3); + assert_eq!(slot_count(24 << 30, 0), 8); + assert_eq!(slot_count(u64::MAX, 0), MAX_SLOTS); + assert_eq!(slot_count(12 << 30, 6 << 30), 2); + assert_eq!(slot_count(20 << 30, 6 << 30), 4); + assert_eq!(slot_count(24 << 30, 6 << 30), 6); +} + +#[test] +fn remote_count_follows_memory_with_bounds() { + assert_eq!(remote_slot_count(0, 0), 1); + assert_eq!(remote_slot_count(19 << 30, 0), 12); + assert_eq!(remote_slot_count(19 << 30, 6 << 30), 8); + assert_eq!(remote_slot_count(u64::MAX, 0), MAX_SLOTS); + let extras = |memory, reserved| { + remote_slot_count(memory, reserved).saturating_sub(slot_count(memory, reserved)) + }; + assert_eq!(extras(19 << 30, 0), 6); + assert_eq!(extras(19 << 30, 6 << 30), 4); + assert_eq!(extras(2 << 30, 0), 0); +} + +#[test] +fn extras_sit_above_the_pool() { + let rules_dx = Gate::new(PathBuf::from("/slots"), 4, "r".to_owned(), false) + .reserving(6) + .extra(4, true) + .layout(); + assert_eq!( + ( + rules_dx.count, + rules_dx.reserve.clone(), + rules_dx.extras.clone() + ), + (4, 4..6, 6..10) + ); + assert_eq!( + rules_dx.counted().collect::>(), + [0, 1, 2, 3, 6, 7, 8, 9] + ); + let consumer = Gate::new(PathBuf::from("/slots"), 6, "c".to_owned(), false) + .reserving(6) + .extra(6, false) + .layout(); + assert_eq!( + (consumer.count, consumer.reserve, consumer.extras), + (6, 6..6, 6..12) + ); +} + +#[derive(Debug)] +struct Fixed(Option); + +impl Pressure for Fixed { + fn high(&self) -> Option { + self.0 + } +} + +fn remote_gate(dir: &Path, remote: bool, high: Option) -> Gate { + Gate::new(dir.to_path_buf(), 1, "remote".to_owned(), false) + .extra(2, remote) + .with_pressure(Arc::new(Fixed(high))) +} + +fn free(dir: &Path, index: usize) -> bool { + matches!(lock_slot(&slot_path(dir, index), false), Ok(Some(_))) +} + +#[test] +fn a_slot_without_a_holder_file_still_counts_as_held() { + let scratch = dx_test_scratch::scratch("dx-slots-no-holder-"); + let dir = scratch.path().join("slots"); + let slot = try_take(&dir, 2, "busy").expect("try").expect("slot"); + std::fs::remove_file(holder_path(&dir, slot.index)).expect("remove holder"); + assert_eq!(held(&dir, 0..2, usize::MAX), 2); +} + +#[test] +fn a_local_gate_never_takes_an_extra() { + let scratch = dx_test_scratch::scratch("dx-slots-local-extra-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); + let gate = remote_gate(&dir, false, Some(false)); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + std::thread::sleep(POLL * 2); + assert!(free(&dir, 1) && free(&dir, 2)); + drop(fill); + assert_eq!(waiter.join().expect("join").0.index, 0); +} + +#[test] +fn a_remote_gate_takes_a_base_slot_then_an_extra_when_calm() { + let scratch = dx_test_scratch::scratch("dx-slots-remote-extra-"); + let dir = scratch.path().join("slots"); + let gate = remote_gate(&dir, true, Some(false)); + let (base, base_err, _) = take_gate(&gate); + let (extra, extra_err, _) = take_gate(&gate); + assert_eq!((base.index, extra.index), (0, 1)); + assert_eq!((base_err.as_str(), extra_err.as_str()), ("", "")); + assert!(extra_mark(&dir).is_file()); +} + +#[test] +fn a_remote_gate_waits_under_pressure_or_without_a_signal() { + for high in [Some(true), None] { + let scratch = dx_test_scratch::scratch("dx-slots-remote-pressure-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); + let gate = remote_gate(&dir, true, high); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + std::thread::sleep(POLL * 2); + assert!(free(&dir, 1) && free(&dir, 2)); + drop(fill); + let (slot, err, _) = waiter.join().expect("join"); + assert_eq!(slot.index, 0); + let want = match high { + Some(true) => wait_message("fill; extra slots paused under memory pressure"), + _ => wait_message("fill"), + }; + assert_eq!(err, format!("dx: {want}\n")); + } +} + +#[test] +fn a_remote_waiter_does_not_jump_the_line_through_a_free_extra() { + let scratch = dx_test_scratch::scratch("dx-slots-remote-line-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); + let queued = enqueue(&dir, "").expect("queued"); + let gate = remote_gate(&dir, true, Some(false)); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + std::thread::sleep(POLL * 2); + assert!(!waiter.is_finished(), "a remote waiter must keep its place"); + assert!(free(&dir, 1) && free(&dir, 2)); + drop(queued); + let (slot, err, _) = waiter.join().expect("join"); + assert_eq!(slot.index, 1); + assert_eq!( + err, + format!("dx: {}\n", wait_message("fill, 1 ahead in line")) + ); + drop(fill); +} + +#[test] +fn held_extras_under_pressure_add_no_paused_suffix() { + let scratch = dx_test_scratch::scratch("dx-slots-remote-full-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); + let extras: Vec = (1..3) + .map(|index| { + scan(&dir, index..index + 1, &format!("extra-{index}"), &|_| true) + .0 + .expect("scan") + .expect("extra") + }) + .collect(); + // Each clock read is past the cap, so the second read releases the pause. + let start = Instant::now(); + let ticks = std::sync::atomic::AtomicU32::new(0); + let clock = move || { + let n = ticks.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + start + PRESSURE_WAIT * (n + 1) + }; + let gate = remote_gate(&dir, true, Some(true)).with_clock(clock); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + drop(fill); + let (slot, err, _) = waiter.join().expect("join"); + assert_eq!(slot.index, 0); + assert_eq!(err, format!("dx: {}\n", wait_message("fill and 2 more"))); + drop(extras); +} + +#[derive(Debug)] +struct Switch(std::sync::atomic::AtomicBool); + +impl Pressure for Switch { + fn high(&self) -> Option { + Some(self.0.load(std::sync::atomic::Ordering::SeqCst)) + } +} + +/// A clock that starts now and moves only when told. +fn fake_clock() -> ( + impl Fn() -> Instant + Send + Sync + 'static, + Arc>, +) { + let start = Instant::now(); + let offset = Arc::new(Mutex::new(Duration::ZERO)); + let read = offset.clone(); + (move || start + *read.lock().expect("clock"), offset) +} + +#[test] +fn a_new_gate_reads_no_host_pressure() { + let gate = Gate::new(PathBuf::from("/slots"), 1, "g".to_owned(), false); + assert_eq!(gate.pressure.high(), None); +} + +fn base_gate(dir: &Path, high: Arc) -> Gate { + Gate::new(dir.to_path_buf(), 2, "gate".to_owned(), true).with_pressure(high) +} + +#[test] +fn a_free_base_slot_waits_under_pressure_while_another_is_held() { + let scratch = dx_test_scratch::scratch("dx-slots-base-pressure-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); + let switch = Arc::new(Switch(std::sync::atomic::AtomicBool::new(true))); + let (waiter, rx) = spawn_take(base_gate(&dir, switch.clone())); + announced(&rx); + std::thread::sleep(POLL * 2); + assert!(!waiter.is_finished()); + switch.0.store(false, std::sync::atomic::Ordering::SeqCst); + let (slot, err, events) = waiter.join().expect("join"); + assert_eq!(slot.index, 1); + assert_eq!( + err, + format!( + "dx: {}\n", + wait_message("fill; paused under memory pressure") + ) + ); + assert!(events.contains("\"reason\":\"pressure\""), "{events}"); + drop(fill); +} + +#[test] +fn the_pause_ends_after_the_cap_even_under_pressure() { + let scratch = dx_test_scratch::scratch("dx-slots-base-cap-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); + let (clock, offset) = fake_clock(); + let gate = base_gate(&dir, Arc::new(Fixed(Some(true)))).with_clock(clock); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + std::thread::sleep(POLL * 2); + assert!(!waiter.is_finished()); + *offset.lock().expect("clock") = PRESSURE_WAIT; + let (slot, _, _) = waiter.join().expect("join"); + assert_eq!(slot.index, 1); + drop(fill); +} + +#[test] +fn nothing_pauses_when_no_other_slot_is_held_or_there_is_no_signal() { + let scratch = dx_test_scratch::scratch("dx-slots-base-free-"); + let dir = scratch.path().join("slots"); + let gate = base_gate(&dir, Arc::new(Fixed(Some(true)))); + let (slot, err, _) = take_gate(&gate); + assert_eq!((slot.index, err.as_str()), (0, "")); + let blind = base_gate(&dir, Arc::new(Fixed(None))); + let (other, err, _) = take_gate(&blind); + assert_eq!((other.index, err.as_str()), (1, "")); +} + +#[test] +fn a_normal_wait_reports_the_held_reason() { + let scratch = dx_test_scratch::scratch("dx-slots-base-held-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); + let gate = Gate::new(dir.clone(), 1, "gate".to_owned(), true) + .with_pressure(Arc::new(Fixed(Some(false)))); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + drop(fill); + let (_, _, events) = waiter.join().expect("join"); + assert!(events.contains("\"reason\":\"held\""), "{events}"); +} + +#[test] +fn a_local_rules_dx_gate_counts_a_held_extra_but_not_the_reserve() { + let scratch = dx_test_scratch::scratch("dx-slots-local-counted-"); + let dir = scratch.path().join("slots"); + let layout = Gate::new(dir.clone(), 4, "r".to_owned(), false) + .reserving(6) + .extra(4, false) + .layout(); + let take = |index: usize| { + scan(&dir, index..index + 1, "other", &|_| true) + .0 + .expect("scan") + .expect("slot") + }; + let reserve = take(4); + assert_eq!(held(&dir, layout.counted(), 0), 1); + let extra = take(6); + assert_eq!(held(&dir, layout.counted(), 0), 2); + let base = take(1); + assert_eq!(held(&dir, layout.counted(), 0), 3); + drop((reserve, extra, base)); +} + +#[test] +fn extra_grants_are_spaced_out() { + let scratch = dx_test_scratch::scratch("dx-slots-remote-spacing-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 1, "fill").expect("try").expect("fill"); + std::fs::write(extra_mark(&dir), "earlier").expect("mark"); + let gate = remote_gate(&dir, true, Some(false)); + let waiting = gate.clone(); + let (waiter, rx) = spawn_take(waiting); + announced(&rx); + std::thread::sleep(POLL * 2); + assert!(free(&dir, 1) && free(&dir, 2)); + drop(fill); + let base = waiter.join().expect("join").0; + assert_eq!(base.index, 0); + let old = SystemTime::now() - EXTRA_SPACING - Duration::from_secs(1); + File::options() + .write(true) + .open(extra_mark(&dir)) + .and_then(|file| file.set_modified(old)) + .expect("age mark"); + let (extra, err, _) = take_gate(&gate); + assert_eq!((extra.index, err.as_str()), (1, "")); +} + +#[test] +fn a_held_extra_counts_toward_the_share() { + alone("a_held_extra_counts_toward_the_share", || { + let scratch = dx_test_scratch::scratch("dx-slots-remote-share-"); + let dir = scratch.path().join("slots"); + let gate = remote_gate(&dir, true, Some(false)).sharing(12, 12 << 30, 0); + let (base, _, _) = take_gate(&gate); + let (extra, _, _) = take_gate(&gate); + assert_eq!(extra.index, 1); + let share = extra.share().expect("share"); + assert_eq!((share.cpus, share.memory_mb), (6, 6 << 10)); + drop(base); + let local = remote_gate(&dir, false, None).sharing(12, 12 << 30, 0); + let (again, _, _) = take_gate(&local); + assert_eq!(again.index, 0); + assert_eq!(again.share().expect("share").memory_mb, 6 << 10); + }); +} + +#[test] +fn share_splits_the_host_over_held_slots() { + let share = |cpus, memory, reserved, held| { + let share = local_share(cpus, memory, reserved, held); + (share.cpus, share.memory_mb) + }; + assert_eq!(share(12, 19 << 30, 0, 1), (12, 19 << 10)); + assert_eq!(share(12, 19 << 30, 0, 2), (6, 9728)); + assert_eq!(share(12, 19 << 30, 0, 4), (3, 4864)); + assert_eq!(share(4, 8 << 30, 0, 4), (2, 2048)); + assert_eq!(share(1, 8 << 30, 0, 1), (2, 8192)); + assert_eq!(share(12, 19 << 30, 6 << 30, 2), (6, 6656)); + assert_eq!(share(12, 4 << 30, 6 << 30, 2), (6, 1)); + assert_eq!(share(12, 19 << 30, 0, 0), (12, 19 << 10)); +} + +#[cfg(unix)] +#[test] +fn a_child_forked_while_a_slot_is_held_keeps_it_until_exec() { + use std::io::Read; + use std::os::fd::AsRawFd; + use std::os::unix::net::UnixStream; + use std::os::unix::process::CommandExt; + let scratch = dx_test_scratch::scratch("dx-slots-fork-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + let slot = try_take(&dir, 1, "held").expect("try").expect("free"); + let (mut reader, writer) = UnixStream::pair().expect("pair"); + let fd = writer.as_raw_fd(); + let spawner = std::thread::spawn(move || { + let mut cmd = Process::new("/bin/sh"); + cmd.args(["-c", "exit 0"]); + // SAFETY: the hook only calls write and nanosleep, both async-signal-safe. + unsafe { + cmd.pre_exec(move || { + let byte = 1u8; + libc::write(fd, (&byte as *const u8).cast(), 1); + let nap = libc::timespec { + tv_sec: 0, + tv_nsec: 150_000_000, + }; + libc::nanosleep(&nap, std::ptr::null_mut()); + Ok(()) + }); + } + let mut child = cmd.spawn().expect("spawn"); + drop(writer); + child.wait().expect("wait"); + }); + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte).expect("forked"); + drop(slot); + assert!( + try_take(&dir, 1, "probe").expect("try").is_none(), + "the forked child holds the lock until it execs" + ); + assert!(slot_settles_free(&dir, 1)); + spawner.join().expect("spawner"); +} + +#[test] +fn held_counts_locked_slots_and_the_caller() { + let scratch = dx_test_scratch::scratch("dx-slots-held-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + assert_eq!(held(&dir, 0..3, 0), 1); + let mut child = spawn_holder(&dir); + assert_eq!(held(&dir, 0..3, 1), 2); + assert_eq!(held(&dir, 0..3, 0), 1); + let _ = child.kill(); + let _ = child.wait(); + std::fs::write(holder_path(&dir, 2), "gone").expect("stale holder"); + assert!(holder_path(&dir, 0).exists()); + assert_eq!(held(&dir, 0..3, 1), 1); + assert!(slot_settles_free(&dir, 1)); +} + +#[test] +fn a_taken_slot_sets_the_share_until_it_drops() { + alone( + "a_taken_slot_sets_the_share_until_it_drops", + take_sets_the_share, + ); +} + +fn take_sets_the_share() { + let scratch = dx_test_scratch::scratch("dx-slots-take-share-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 2, "me".to_owned(), false).sharing(600, 9 << 30, 1 << 30); + let other = try_take(&dir, 2, "other").expect("try").expect("other"); + let (slot, _, _) = take_gate(&gate); + let want = dx_process::LocalShare { + cpus: 300, + memory_mb: 4096, + }; + assert_eq!(slot.share(), Some(want)); + assert_eq!(dx_process::share(), Some(want)); + drop(slot); + assert_eq!(dx_process::share(), None); + drop(other); + let plain = Gate::new(dir, 2, "plain".to_owned(), false); + let (slot, _, _) = take_gate(&plain); + assert_eq!(slot.share(), None); + assert_eq!(dx_process::share(), None); +} + +#[test] +fn share_shrinks_when_another_slot_is_taken() { + alone("share_shrinks_when_another_slot_is_taken", || { + let scratch = dx_test_scratch::scratch("dx-slots-live-share-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 2, "me".to_owned(), false).sharing(12, 12 << 30, 0); + let (slot, _, _) = take_gate(&gate); + let cpus = || dx_process::share().map(|share| share.cpus); + assert_eq!(cpus(), Some(12)); + let other = try_take(&dir, 2, "other").expect("try").expect("other"); + assert_eq!(cpus(), Some(6)); + drop(other); + assert_eq!(cpus(), Some(12)); + drop(slot); + assert_eq!(cpus(), None); + }); +} + +#[test] +fn slot_runner_exports_the_current_share() { + use dx_process::Runner as _; + alone("slot_runner_exports_the_current_share", || { + let scratch = dx_test_scratch::scratch("dx-slots-live-env-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 2, "me".to_owned(), false).sharing(12, 12 << 30, 0); + let (slot, _, _) = take_gate(&gate); + let inner = EnvRecorder::default(); + let held = slot.runner(&inner); + held.run(&["bazel".to_owned()], scratch.path(), &[]) + .expect("run"); + let other = try_take(&dir, 2, "other").expect("try").expect("other"); + held.run(&["bazel".to_owned()], scratch.path(), &[]) + .expect("run"); + drop(other); + let shares: Vec = inner + .seen + .borrow() + .iter() + .flat_map(|(_, env)| env.iter()) + .filter(|(name, _)| name == SHARE_ENV_VAR) + .map(|(_, value)| value.clone()) + .collect(); + assert_eq!(shares, vec!["cpu=12,memory=12288", "cpu=6,memory=6144"]); + }); +} + +#[test] +fn a_nested_call_reuses_the_parent_share() { + alone( + "a_nested_call_reuses_the_parent_share", + nested_reuses_the_share, + ); +} + +fn nested_reuses_the_share() { + let env = |slot: Option<&'static str>, share: &'static str| { + move |name: &str| match name { + SLOT_ENV_VAR => slot.map(str::to_owned), + SHARE_ENV_VAR => Some(share.to_owned()), + _ => None, + } + }; + dx_process::set_share(None); + inherit_share(&env(None, "cpu=5,memory=700")); + assert_eq!(dx_process::share(), None); + inherit_share(&env(Some("1"), "cpu=5,memory=bad")); + assert_eq!(dx_process::share(), None); + inherit_share(&env(Some("1"), "cpu=5,memory=700")); + let want = dx_process::LocalShare { + cpus: 5, + memory_mb: 700, + }; + assert_eq!(dx_process::share(), Some(want)); + dx_process::clear_share(&want); +} + +fn rules_dx_layout(prefix: &str) -> tempfile::TempDir { + let scratch = dx_test_scratch::scratch(prefix); + std::fs::create_dir_all(scratch.path().join("cli/cli")).expect("dirs"); + std::fs::write(scratch.path().join("cli/cli/BUILD.bazel"), "").expect("build"); + std::fs::write( + scratch.path().join("MODULE.bazel"), + "module(\n name = \"rules_dx\",\n version = \"0.0.0\",\n)\n", + ) + .expect("module"); + scratch +} + +#[test] +fn consumer_workspaces_reserve_nothing() { + let none = |_: &str| None; + assert_eq!(nested_reserve(Path::new("/ws"), &none), 0); + let scratch = dx_test_scratch::scratch("dx-slots-consumer-"); + std::fs::create_dir_all(scratch.path().join("cli/cli")).expect("dirs"); + std::fs::write(scratch.path().join("cli/cli/BUILD.bazel"), "").expect("build"); + std::fs::write( + scratch.path().join("MODULE.bazel"), + "module(\n name = \"my_app\",\n)\nbazel_dep(name = \"rules_dx\", version = \"1.0.0\")\n", + ) + .expect("module"); + assert_eq!(nested_reserve(scratch.path(), &none), 0); +} + +#[test] +fn rules_dx_gate_spans_the_whole_pool() { + let scratch = rules_dx_layout("dx-slots-pool-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + let memory = available_memory().expect("memory"); + let gate = Gate::from_env(scratch.path(), &get, false, &[], BuildSlots::Auto).expect("gate"); + let resources = gate.resources.expect("resources"); + let cpus = std::thread::available_parallelism().expect("cpus").get(); + assert_eq!(resources.cpus, u64::try_from(cpus).expect("u64")); + assert_eq!((resources.memory, resources.reserved), (memory, 6 << 30)); + assert_eq!(gate.count, slot_count(memory, 6 << 30)); + assert_eq!(gate.pool, slot_count(memory, 0)); + let consumer = + Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Auto).expect("gate"); + assert_eq!(consumer.pool, consumer.count); + let narrow = Gate::new(PathBuf::from("/slots"), 3, "r".to_owned(), false).reserving(1); + assert_eq!(narrow.pool, 3); +} + +#[test] +fn rules_dx_reserves_the_default_nested_servers() { + let scratch = rules_dx_layout("dx-slots-rules-dx-"); + assert_eq!(nested_reserve(scratch.path(), &|_| None), 3 * (2 << 30)); +} + +#[test] +fn rules_dx_reserve_is_zero_when_tag_filters_exclude_nested_bazel() { + for (filters, reserved) in [ + ("-resources:nested_bazel:1,-exclusive", false), + ("-resources:nested_bazel", false), + ("-exclusive", true), + ("smoke", false), + ("smoke,resources:nested_bazel:1", true), + ] { + let scratch = rules_dx_layout("dx-slots-tag-filters-"); + std::fs::write( + scratch.path().join(".bazelrc"), + format!("test --test_tag_filters={filters}\n"), + ) + .expect("rc"); + let want = if reserved { 3 * (2 << 30) } else { 0 }; + assert_eq!(nested_reserve(scratch.path(), &|_| None), want, "{filters}"); + } +} + +#[test] +fn rules_dx_reserve_follows_the_nested_slot_env() { + let scratch = rules_dx_layout("dx-slots-rules-dx-env-"); + let with = |value: &'static str| { + move |name: &str| (name == NESTED_BAZEL_SLOTS_ENV).then(|| value.to_owned()) + }; + assert_eq!(nested_reserve(scratch.path(), &with("5")), 10 << 30); + for fallback in ["0", "x", ""] { + assert_eq!(nested_reserve(scratch.path(), &with(fallback)), 6 << 30); + } +} + +#[test] +fn auto_gate_reserve_is_the_shared_reserve_range() { + for gib in [4_u64, 8, 12, 16, 24, 64] { + let memory = gib << 30; + let reserved = u64::from(DEFAULT_NESTED_BAZEL_SLOTS as u16) * BYTES_PER_NESTED_BAZEL; + let size = sizing(BuildSlots::Auto, memory, reserved).expect("sizing"); + let range = dx_process::slot_pool::reserve_range(memory, DEFAULT_NESTED_BAZEL_SLOTS); + assert_eq!(size.count..size.pool, range, "{gib} GiB"); + } +} + +#[test] +fn a_consumer_gate_leaves_the_held_reserve_alone() { + let scratch = dx_test_scratch::scratch("dx-slots-reserve-held-"); + let dir = scratch.path().join("dx").join("slots"); + let memory = 16_u64 << 30; + let reserve = dx_process::slot_pool::reserve_range(memory, DEFAULT_NESTED_BAZEL_SLOTS); + let held = dx_process::slot_pool::hold_reserve(&dir, reserve.clone()); + assert!(held.busy.is_empty()); + let count = reserve.start; + let slots: Vec = (0..count) + .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("slot")) + .collect(); + assert!(slots.iter().all(|slot| slot.index < count)); + for index in reserve { + assert!(!free(&dir, index), "reserve index {index} is held"); + } +} + +#[test] +fn slot_memory_parses_sizes_and_scales_counts() { + assert_eq!(parse_slot_memory("4g"), Some(4 << 30)); + assert_eq!(parse_slot_memory(" 512M "), Some(512 << 20)); + assert_eq!(parse_slot_memory("2048"), Some(2048 << 20)); + for bad in ["", "0", "g", "-1g", "1.5g", "4x"] { + assert_eq!(parse_slot_memory(bad), None, "{bad}"); + } + let env = + |value: &'static str| move |name: &str| (name == SLOT_MEMORY_ENV).then(|| value.to_owned()); + assert_eq!(slot_bytes(&env("6g")), 6 << 30); + assert_eq!(slot_bytes(&env("nope")), BYTES_PER_SLOT); + assert_eq!(slot_bytes(&|_: &str| None), BYTES_PER_SLOT); + let memory = per_slot_scaled(24 << 30, 6 << 30); + assert_eq!(slot_count(memory, 0), 4); + assert_eq!( + slot_count(per_slot_scaled(24 << 30, 1 << 30), 0), + MAX_SLOTS.min(24) + ); +} + +#[test] +fn available_memory_text_parses() { + let text = "MemTotal: 4096 kB\nMemAvailable: 2048 kB\n"; + assert_eq!(parse_mem_available(text), Some(2 << 20)); + assert_eq!(parse_mem_available("MemAvailable: x kB\n"), None); + assert_eq!(parse_mem_available("MemTotal: 1 kB\n"), None); +} + +#[test] +fn memory_text_parses() { + assert_eq!( + parse_meminfo("MemFree: 1 kB\nMemTotal: 2048 kB\n"), + Some(2048 * 1024) + ); + assert_eq!(parse_meminfo("MemFree: 1 kB\n"), None); + assert_eq!(parse_cgroup_limit("8589934592\n"), Some(8 << 30)); + assert_eq!(parse_cgroup_limit("max\n"), None); + assert_eq!(parse_cgroup_limit("9223372036854771712\n"), None); + assert_eq!(parse_cgroup_limit("9223372036854710272\n"), None); + assert_eq!(parse_cgroup_limit("-1\n"), None); + assert_eq!(parse_cgroup_limit("2147483648\n"), Some(2 << 30)); +} + +#[test] +fn extras_follow_the_cgroup_limit() { + let scratch = dx_test_scratch::scratch("dx-cgroup-extras-"); + let root = scratch.path(); + std::fs::create_dir_all(root.join("a")).expect("dirs"); + std::fs::write(root.join("a/memory.max"), format!("{}\n", 6u64 << 30)).expect("write"); + let host = 19u64 << 30; + let limit = cgroup_limit_in("0::/a\n", root).expect("limit"); + let memory = host.min(limit); + let extras = remote_slot_count(memory, 0).saturating_sub(slot_count(memory, 0)); + let host_extras = remote_slot_count(host, 0).saturating_sub(slot_count(host, 0)); + assert!(extras < host_extras); + assert_eq!(extras, 2); +} + +#[test] +fn cgroup_limit_reads_v1_and_v2() { + let put = |root: &Path, rel: &str, text: &str| { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).expect("dirs"); + std::fs::write(path, text).expect("write"); + }; + let scratch = dx_test_scratch::scratch("dx-cgroup-v2-"); + let root = scratch.path(); + put(root, "a/memory.max", "1000\n"); + std::fs::create_dir_all(root.join("a/b")).expect("dirs"); + assert_eq!(cgroup_limit_in("0::/a/b\n", root), Some(1000)); + assert_eq!(cgroup_limit_in("", root), None); + + let scratch = dx_test_scratch::scratch("dx-cgroup-v1-"); + let root = scratch.path(); + put(root, "memory/memory.limit_in_bytes", "2000\n"); + assert_eq!(cgroup_limit_in("4:memory:/docker/x\n", root), Some(2000)); + assert_eq!(cgroup_limit_in("5:cpu,memory:/x\n", root), Some(2000)); + assert_eq!(cgroup_limit_in("5:cpu:/x\n", root), None); + put(root, "a/memory.max", "3000\n"); + let hybrid = "4:memory:/docker/x\n0::/a\n"; + assert_eq!(cgroup_limit_in(hybrid, root), Some(2000)); + put(root, "a/memory.max", "1500\n"); + assert_eq!(cgroup_limit_in(hybrid, root), Some(1500)); + put(root, "memory/p/c/memory.limit_in_bytes", "5000\n"); + put(root, "memory/p/memory.limit_in_bytes", "700\n"); + assert_eq!(cgroup_limit_in("4:memory:/p/c\n", root), Some(700)); + + let scratch = dx_test_scratch::scratch("dx-cgroup-v1-unlimited-"); + let root = scratch.path(); + put( + root, + "memory/memory.limit_in_bytes", + "9223372036854771712\n", + ); + assert_eq!(cgroup_limit_in("4:memory:/x\n", root), None); +} + +#[derive(Default)] +struct EnvRecorder { + #[allow(clippy::type_complexity)] + seen: RefCell)>>, +} + +impl EnvRecorder { + fn record(&self, method: &'static str, env: &[(&str, &str)]) { + let env = env + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + self.seen.borrow_mut().push((method, env)); + } +} + +impl dx_process::Runner for EnvRecorder { + fn run( + &self, + _argv: &[String], + _cwd: &Path, + env: &[(&str, &str)], + ) -> io::Result { + self.record("run", env); + Ok(dx_process::ChildStatus::exited(0)) + } + + fn run_hermetic( + &self, + _argv: &[String], + _cwd: &Path, + env: &[(&str, &str)], + ) -> io::Result { + self.record("run_hermetic", env); + Ok(dx_process::ChildStatus::exited(3)) + } + + fn run_withdrawable( + &self, + _argv: &[String], + _cwd: &Path, + env: &[(&str, &str)], + _withdraw: &dyn Fn() -> bool, + ) -> io::Result> { + self.record("run_withdrawable", env); + Ok(Some(dx_process::ChildStatus::exited(4))) + } + + fn free_disk_bytes(&self, _path: &Path) -> Option { + Some(9) + } + + fn pinned_tool(&self, _var: &str) -> dx_process::PinnedVar { + dx_process::PinnedVar::NotAbsolute + } +} + +#[test] +fn slot_runner_forwards_the_pinned_tool_state() { + use dx_process::Runner as _; + let scratch = dx_test_scratch::scratch("dx-slots-pinned-"); + let dir = scratch.path().join("slots"); + let slot = try_take(&dir, 1, "pinned").expect("try").expect("free"); + let inner = EnvRecorder::default(); + let held = slot.runner(&inner); + assert_eq!( + held.pinned_tool("DX_X_BIN"), + dx_process::PinnedVar::NotAbsolute + ); +} + +#[test] +fn slot_runner_marks_children_while_the_slot_is_held() { + use dx_process::Runner as _; + let scratch = dx_test_scratch::scratch("dx-slots-env-"); + let dir = scratch.path().join("slots"); + let slot = try_take(&dir, 1, "env").expect("try").expect("free"); + let inner = EnvRecorder::default(); + let held = slot.runner(&inner); + let argv = ["bazel".to_owned()]; + let cwd = scratch.path(); + let env = [("KEEP", "yes")]; + assert_eq!(held.run(&argv, cwd, &env).expect("run").code, Some(0)); + assert_eq!( + held.run_withdrawable(&argv, cwd, &env, &|| true) + .expect("withdrawable") + .map(|status| status.code), + Some(Some(4)) + ); + assert_eq!( + held.run_hermetic(&argv, cwd, &env).expect("hermetic").code, + Some(3) + ); + assert_eq!(held.free_disk_bytes(cwd), Some(9)); + let marked = vec![ + ("KEEP".to_owned(), "yes".to_owned()), + (SLOT_ENV_VAR.to_owned(), "1".to_owned()), + ]; + let plain = vec![("KEEP".to_owned(), "yes".to_owned())]; + assert_eq!( + *inner.seen.borrow(), + vec![ + ("run", marked.clone()), + ("run_withdrawable", marked), + ("run_hermetic", plain), + ] + ); +} + +#[test] +fn only_bazel_commands_take_slots() { + for command in [Command::Build, Command::Test, Command::Coverage] { + assert!(needs_slot(command)); + } + for command in [Command::Run, Command::Watch, Command::Update] { + assert!(!needs_slot(command)); + } + for command in [ + Command::Lint, + Command::Typecheck, + Command::Format, + Command::Deploy, + Command::Generate, + Command::Sync, + Command::Security, + Command::License, + Command::Docs, + ] { + assert!(needs_slot(command)); + } + for command in [Command::Status, Command::Worktree, Command::Completion] { + assert!(!needs_slot(command)); + } +} + +#[test] +fn slot_runner_passes_the_share_to_children() { + use dx_process::Runner as _; + let scratch = dx_test_scratch::scratch("dx-slots-share-env-"); + let dir = scratch.path().join("slots"); + let mut slot = try_take(&dir, 1, "env").expect("try").expect("free"); + slot.sizing = Some(Arc::new(ShareSizing { + dir: dir.clone(), + indices: vec![0], + own: 0, + resources: Resources { + cpus: 7, + memory: 777 << 20, + reserved: 0, + }, + })); + let inner = EnvRecorder::default(); + let held = slot.runner(&inner); + held.run(&["bazel".to_owned()], scratch.path(), &[]) + .expect("run"); + assert_eq!( + *inner.seen.borrow(), + vec![( + "run", + vec![ + (SLOT_ENV_VAR.to_owned(), "1".to_owned()), + (SHARE_ENV_VAR.to_owned(), "cpu=7,memory=777".to_owned()), + ] + )] + ); +} + +#[test] +fn a_gate_ignores_slot_env_set_after_it_was_built() { + let scratch = dx_test_scratch::scratch("dx-slots-gate-env-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let nested = std::cell::Cell::new(false); + let get = |name: &str| match name { + "XDG_CACHE_HOME" => Some(cache.clone()), + SLOT_ENV_VAR => nested.get().then(|| "1".to_owned()), + _ => None, + }; + let gate = Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Auto).expect("gate"); + nested.set(true); + assert!(Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Auto).is_none()); + let slot = with_gate(Some(gate), || { + take(Command::Run, &mut Vec::new(), &mut Vec::new()) + }); + assert!(matches!( + slot, + Ok(Some(Slot { + sizing: Some(_), + .. + })) + )); + assert!( + scratch + .path() + .join("dx") + .join("slots") + .join("slot-0.lock") + .exists() + ); +} + +fn counting( + message: Option<&'static str>, +) -> ( + Arc, + impl Fn() -> Option + Send + Sync + 'static, +) { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = Arc::clone(&calls); + let reclaim = move || { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + message.map(str::to_owned) + }; + (calls, reclaim) +} + +const RECLAIMED: &str = + "low disk: 5.0 GiB free; removed the output bases of free slot(s) 2; 30.0 GiB free now"; + +#[test] +fn take_runs_the_reclaimer_once_and_reports_it() { + for json in [true, false] { + let scratch = dx_test_scratch::scratch("dx-slots-reclaim-"); + let (calls, reclaim) = counting(Some(RECLAIMED)); + let gate = + Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), json).reclaiming(reclaim); + let (_slot, err, events) = take_gate(&gate); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(err, format!("dx: {RECLAIMED}\n")); + if !json { + assert!(events.is_empty(), "text mode writes no events"); + continue; + } + let notices: Vec = events + .lines() + .map(|line| serde_json::from_str(line).expect("json")) + .collect(); + assert_eq!(notices.len(), 1, "{events}"); + assert_eq!(notices[0]["event"], "notice"); + assert_eq!(notices[0]["severity"], "warning"); + assert_eq!(notices[0]["code"], "disk_low"); + assert_eq!(notices[0]["message"], RECLAIMED); + assert_eq!(notices[0]["related_command"], "test"); + } +} + +#[test] +fn hold_for_a_command_without_a_build_slot_never_reclaims() { + let scratch = dx_test_scratch::scratch("dx-slots-no-reclaim-"); + let (calls, reclaim) = counting(Some(RECLAIMED)); + let gate = + Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), true).reclaiming(reclaim); + for command in [ + Command::Worktree, + Command::Status, + Command::Clean, + Command::Watch, + ] { + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = gate.hold(command, &mut err, &mut events).expect("hold"); + assert!(slot.is_none(), "{command:?}"); + assert!(err.is_empty() && events.is_empty(), "{command:?}"); + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + let mut err = Vec::new(); + let slot = gate + .hold(Command::Build, &mut err, &mut Vec::new()) + .expect("hold"); + assert!(slot.is_some()); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a build reclaims" + ); +} + +#[test] +fn a_quiet_reclaim_prints_nothing() { + let scratch = dx_test_scratch::scratch("dx-slots-quiet-reclaim-"); + let (calls, reclaim) = counting(None); + let gate = + Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), true).reclaiming(reclaim); + let (_slot, err, events) = take_gate(&gate); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(err.is_empty(), "{err}"); + assert!(events.is_empty(), "{events}"); +} + +#[test] +fn take_reclaims_when_slots_are_disabled() { + let scratch = dx_test_scratch::scratch("dx-slots-disabled-reclaim-"); + let blocker = scratch.path().join("slots"); + std::fs::write(&blocker, b"").expect("blocker"); + let (calls, reclaim) = counting(Some(RECLAIMED)); + let gate = Gate::new(blocker, 1, "me".to_owned(), false).reclaiming(reclaim); + let mut err = Vec::new(); + let slot = gate + .take(Command::Test, &mut err, &mut Vec::new()) + .expect("take"); + assert!(slot.is_none()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + let text = String::from_utf8(err).expect("utf8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].starts_with("dx: build slots disabled: "), "{text}"); + assert_eq!(lines[1], format!("dx: {RECLAIMED}")); +} + +#[test] +fn a_nested_take_without_a_gate_never_reclaims() { + let scratch = dx_test_scratch::scratch("dx-slots-nested-reclaim-"); + let (calls, reclaim) = counting(Some(RECLAIMED)); + let gate = + Gate::new(scratch.path().join("slots"), 1, "me".to_owned(), false).reclaiming(reclaim); + with_gate(Some(gate), || { + with_gate(None, || { + take(Command::Build, &mut Vec::new(), &mut Vec::new()).expect("take") + }) + }); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); +} + +#[test] +fn take_without_a_gate_takes_nothing() { + let scratch = dx_test_scratch::scratch("dx-slots-no-gate-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 1, "me".to_owned(), false); + with_gate(Some(gate), || { + let slot = take(Command::Run, &mut Vec::new(), &mut Vec::new()).expect("take"); + assert!(slot.is_some()); + assert!( + with_gate(None, || take( + Command::Run, + &mut Vec::new(), + &mut Vec::new() + )) + .expect("none") + .is_none() + ); + }); + assert!( + take(Command::Run, &mut Vec::new(), &mut Vec::new()) + .expect("none") + .is_none() + ); + assert!(slot_settles_free(&dir, 1)); +} + +#[test] +fn a_phase_takes_nothing_until_needed() { + let scratch = dx_test_scratch::scratch("dx-slots-phase-lazy-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 1, "me".to_owned(), false); + with_gate(Some(gate), || { + let mut phase = Phase::new(Command::Run); + let (mut err, mut events) = (Vec::new(), Vec::new()); + assert!(phase.need(false, &mut err, &mut events).unwrap().is_none()); + assert!(phase.slot().is_none()); + assert!(slot_settles_free(&dir, 1)); + }); +} + +#[test] +fn a_phase_takes_once() { + let scratch = dx_test_scratch::scratch("dx-slots-phase-once-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir, 1, "me".to_owned(), false); + with_gate(Some(gate), || { + let mut phase = Phase::new(Command::Run); + let (mut err, mut events) = (Vec::new(), Vec::new()); + assert!(phase.need(true, &mut err, &mut events).unwrap().is_some()); + assert!(phase.need(true, &mut err, &mut events).unwrap().is_some()); + assert!(err.is_empty()); + }); +} + +#[test] +fn a_phase_without_a_gate_holds_nothing() { + let mut phase = Phase::new(Command::Run); + let (mut err, mut events) = (Vec::new(), Vec::new()); + assert!(phase.need(true, &mut err, &mut events).unwrap().is_none()); + assert!(phase.need(true, &mut err, &mut events).unwrap().is_none()); + assert!(phase.slot().is_none()); +} + +#[test] +fn a_released_phase_frees_its_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-phase-release-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 1, "me".to_owned(), false); + with_gate(Some(gate), || { + let mut phase = Phase::new(Command::Run); + let (mut err, mut events) = (Vec::new(), Vec::new()); + assert!(phase.need(true, &mut err, &mut events).unwrap().is_some()); + phase.release(); + assert!(phase.slot().is_none()); + assert!(slot_settles_free(&dir, 1)); + }); +} + +#[test] +fn share_report_counts_held_slots_read_only() { + let scratch = dx_test_scratch::scratch("dx-slots-report-"); + let cache = scratch.path().display().to_string(); + let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + let dir = slots_dir(&get).expect("dir"); + std::fs::create_dir_all(&dir).expect("dir"); + let report = |get: &dyn Fn(&str) -> Option| { + share_report( + scratch.path(), + get, + BuildSlots::Count(2.try_into().expect("two")), + ) + }; + let idle = report(&get).expect("report"); + assert_eq!((idle.held, idle.count), (0, 2)); + let _held = try_take(&dir, 2, "me").expect("try").expect("slot"); + std::fs::write(holder_path(&dir, 0), "me").expect("holder"); + let busy = report(&get).expect("report"); + assert_eq!(busy.held, 1); + assert!(busy.next.cpus <= idle.next.cpus); + assert!(!slot_path(&dir, 1).exists(), "the probe creates no slot"); +} + +#[test] +fn held_count_includes_extra_slots() { + let scratch = dx_test_scratch::scratch("dx-slots-extra-"); + let size = Sizing { + count: 2, + pool: 3, + extras: 1, + }; + assert_eq!(size.counted().collect::>(), [0, 1, 3]); + let _extra = lock_slot(&slot_path(scratch.path(), 3), false).expect("lock"); + assert_eq!(held(scratch.path(), size.counted(), usize::MAX), 2); +} + +#[test] +fn cache_dir_follows_the_environment() { + let get = |name: &str| match name { + "XDG_CACHE_HOME" => Some("/x".to_owned()), + _ => None, + }; + assert_eq!(slots_dir(&get), Some(PathBuf::from("/x/dx/slots"))); + let home = |name: &str| (name == "HOME").then(|| "/h".to_owned()); + assert_eq!(slots_dir(&home), Some(PathBuf::from("/h/.cache/dx/slots"))); + let local = |name: &str| (name == "LOCALAPPDATA").then(|| "/l".to_owned()); + assert_eq!(slots_dir(&local), Some(PathBuf::from("/l/dx/slots"))); + let empty_home = |name: &str| match name { + "HOME" => Some(String::new()), + "LOCALAPPDATA" => Some("/l".to_owned()), + _ => None, + }; + assert_eq!(slots_dir(&empty_home), Some(PathBuf::from("/l/dx/slots"))); + assert_eq!(slots_dir(&|_| None), None); +} + +#[test] +fn a_second_taker_waits_until_the_first_drops() { + let scratch = dx_test_scratch::scratch("dx-slots-wait-"); + let dir = scratch.path().join("slots"); + let first = acquire(&dir, 1, "tree-a", &mut |_| {}).expect("first"); + assert_eq!(first.index, 0); + let mut seen = Vec::new(); + let second_dir = dir.clone(); + let (sender, receiver) = std::sync::mpsc::channel(); + let waiter = std::thread::spawn(move || { + let mut holders = Vec::new(); + let slot = acquire(&second_dir, 1, "tree-b", &mut |holder| { + holders.push(holder.to_owned()); + let _ = sender.send(()); + }) + .expect("second"); + (slot.index, holders) + }); + announced(&receiver); + drop(first); + let (index, holders) = waiter.join().expect("join"); + seen.extend(holders); + assert_eq!(index, 0); + assert_eq!(seen, vec!["tree-a".to_owned()]); + assert_eq!( + wait_message("tree-a"), + "waiting for a build slot (held by tree-a); see dx status, or set --build-slots or DX_BUILD_SLOTS" + ); +} + +#[test] +fn waiters_get_slots_in_arrival_order() { + let scratch = dx_test_scratch::scratch("dx-slots-order-"); + let dir = scratch.path().join("slots"); + let held = try_take(&dir, 1, "first").expect("try").expect("free"); + let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut notices = Vec::new(); + let mut waiters = Vec::new(); + for id in 0..3 { + let (sender, receiver) = std::sync::mpsc::channel(); + let dir = dir.clone(); + let order = order.clone(); + waiters.push(std::thread::spawn(move || { + let slot = acquire(&dir, 1, &format!("w{id}"), &mut |holder| { + sender.send(holder.to_owned()).expect("send"); + }) + .expect("slot"); + order.lock().expect("order").push(id); + std::thread::sleep(Duration::from_millis(50)); + drop(slot); + })); + notices.push( + receiver + .recv_timeout(Duration::from_secs(30)) + .expect("notice"), + ); + } + drop(held); + for waiter in waiters { + waiter.join().expect("join"); + } + assert_eq!(*order.lock().expect("order"), vec![0, 1, 2]); + assert_eq!( + notices, + vec![ + "first".to_owned(), + "first, 1 ahead in line".to_owned(), + "first, 2 ahead in line".to_owned(), + ] + ); + assert!(tickets(&dir).is_empty()); +} + +#[test] +fn a_newcomer_queues_behind_a_waiter() { + let scratch = dx_test_scratch::scratch("dx-slots-newcomer-"); + let dir = scratch.path().join("slots"); + let queued = enqueue(&dir, "").expect("queued"); + let (sender, receiver) = std::sync::mpsc::channel(); + let newcomer_dir = dir.clone(); + let newcomer = std::thread::spawn(move || { + acquire(&newcomer_dir, 1, "newcomer", &mut |holder| { + sender.send(holder.to_owned()).expect("send"); + }) + .expect("slot") + .index + }); + let notice = receiver + .recv_timeout(Duration::from_secs(30)) + .expect("notice"); + assert_eq!(notice, "another build, 1 ahead in line"); + let first = try_take(&dir, 1, "queued").expect("try").expect("free"); + drop(queued); + drop(first); + assert_eq!(newcomer.join().expect("join"), 0); +} + +#[test] +fn an_unlocked_ticket_does_not_block_and_is_swept() { + let scratch = dx_test_scratch::scratch("dx-slots-swept-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(queue_dir(&dir)).expect("queue"); + let dead = queue_dir(&dir).join(format!("ticket-{:020}.lock", 7)); + std::fs::write(&dead, b"").expect("dead ticket"); + std::fs::write(queue_dir(&dir).join("ticket-x.lock"), b"").expect("odd name"); + let slot = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("slot"); + assert_eq!(slot.index, 0); + assert!(!dead.exists()); + assert!(tickets(&dir).is_empty()); +} + +#[test] +fn a_leaving_ticket_never_deletes_the_next_waiters_want() { + let scratch = dx_test_scratch::scratch("dx-slots-leave-"); + let dir = scratch.path().join("slots"); + let mut first = enqueue(&dir, "").expect("first"); + let first_number = first.number; + let mut next = None; + first.leave(|| next = Some(enqueue(&dir, "").expect("next"))); + let next = next.expect("next ticket"); + assert_ne!(next.number, first_number); + drop(first); + assert!(next.path.exists()); + assert!(want_path(&next.path).exists()); + let listed = tickets(&dir); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].1, next.path); +} + +#[test] +fn a_dropped_ticket_leaves_no_file() { + let scratch = dx_test_scratch::scratch("dx-slots-drop-"); + let dir = scratch.path().join("slots"); + drop(enqueue(&dir, "").expect("ticket")); + assert!(tickets(&dir).is_empty()); +} + +#[test] +fn a_stale_ticket_does_not_block() { + let scratch = dx_test_scratch::scratch("dx-slots-stale-"); + let dir = scratch.path().join("slots"); + let stale = enqueue(&dir, "").expect("stale"); + let earlier = SystemTime::now() - Duration::from_secs(60); + stale + .file + .as_ref() + .expect("file") + .set_modified(earlier) + .expect("mtime"); + let slot = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("slot"); + assert_eq!(slot.index, 0); + assert!(stale.path.exists()); +} + +#[test] +fn a_broken_queue_still_gates() { + let scratch = dx_test_scratch::scratch("dx-slots-no-queue-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("slots"); + std::fs::write(queue_dir(&dir), b"").expect("blocker"); + assert!(enqueue(&dir, "").is_err()); + let held = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("held"); + let (sender, receiver) = std::sync::mpsc::channel(); + let waiter_dir = dir.clone(); + let waiter = std::thread::spawn(move || { + acquire(&waiter_dir, 1, "b", &mut |holder| { + sender.send(holder.to_owned()).expect("send"); + }) + .expect("slot") + .index + }); + let notice = receiver + .recv_timeout(Duration::from_secs(30)) + .expect("notice"); + assert_eq!(notice, "a"); + drop(held); + assert_eq!(waiter.join().expect("join"), 0); +} + +#[test] +fn a_freed_slot_clears_its_holder() { + let scratch = dx_test_scratch::scratch("dx-slots-holder-"); + let dir = scratch.path().join("slots"); + let a = try_take(&dir, 3, "a").expect("try").expect("a"); + let b = try_take(&dir, 3, "b").expect("try").expect("b"); + let c = try_take(&dir, 3, "c").expect("try").expect("c"); + assert_eq!(holder(&dir, 0..3, 2), "a and 2 more, 2 ahead in line"); + drop(a); + assert!(!holder_path(&dir, 0).exists()); + assert_eq!(holder(&dir, 0..3, 0), "b and 1 more"); + drop((b, c)); + assert_eq!(holder(&dir, 0..3, 0), "another build"); +} + +#[test] +fn a_free_slot_is_taken_without_waiting() { + let scratch = dx_test_scratch::scratch("dx-slots-free-"); + let dir = scratch.path().join("slots"); + let first = acquire(&dir, 2, "a", &mut |_| {}).expect("first"); + let second = acquire(&dir, 2, "b", &mut |_| panic!("must not wait")).expect("second"); + assert_eq!((first.index, second.index), (0, 1)); +} + +/// A writer that buffers everything and signals once on its first write. +/// +/// `Gate::take` writes the wait line from `on_wait`, which `acquire_reserving` runs only after +/// the waiter is queued and failed to take a slot, so the signal proves the waiter is in line. +struct Announce { + buf: Vec, + signal: Option>, +} + +impl Announce { + fn new() -> (Self, std::sync::mpsc::Receiver<()>) { + let (signal, rx) = std::sync::mpsc::channel(); + let announce = Self { + buf: Vec::new(), + signal: Some(signal), + }; + (announce, rx) + } + + fn text(self) -> String { + String::from_utf8(self.buf).expect("utf8") + } +} + +impl Write for Announce { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.buf.extend_from_slice(data); + if let Some(signal) = self.signal.take() { + let _ = signal.send(()); + } + Ok(data.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Waits for the waiter's announcement; panics rather than hanging when it never comes. +fn announced(rx: &std::sync::mpsc::Receiver<()>) { + rx.recv_timeout(ANNOUNCE_TIMEOUT) + .expect("waiter never announced a wait"); +} + +/// Waits for the announcement, then always drops `held`, so a missing wait fails the caller's +/// assertions instead of hanging `hold()` forever. Returns whether the wait was announced. +fn released_after_wait(rx: &std::sync::mpsc::Receiver<()>, held: T) -> bool { + let seen = rx.recv_timeout(ANNOUNCE_TIMEOUT).is_ok(); + drop(held); + seen +} + +/// Like `take_gate` on a thread; the receiver fires once the thread is waiting in line. +fn spawn_take( + gate: Gate, +) -> ( + std::thread::JoinHandle<(Slot, String, String)>, + std::sync::mpsc::Receiver<()>, +) { + let (mut err, rx) = Announce::new(); + let waiter = std::thread::spawn(move || { + let mut events = Vec::new(); + let slot = gate + .take(Command::Test, &mut err, &mut events) + .expect("take") + .expect("slot"); + (slot, err.text(), String::from_utf8(events).expect("utf8")) + }); + (waiter, rx) +} + +fn take_gate(gate: &Gate) -> (Slot, String, String) { + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = gate + .take(Command::Test, &mut err, &mut events) + .expect("take") + .expect("slot"); + ( + slot, + String::from_utf8(err).expect("utf8"), + String::from_utf8(events).expect("utf8"), + ) +} + +#[test] +fn a_rules_dx_run_keeps_consumers_off_the_reserve() { + let scratch = dx_test_scratch::scratch("dx-slots-reserve-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 1, "r".to_owned(), false).reserving(2); + let (slot, err, _) = take_gate(&gate); + assert_eq!(slot.index, 0); + assert_eq!(err, ""); + assert!(try_take(&dir, 2, "c").expect("try").is_none()); + drop(slot); + assert!(slot_settles_free(&dir, 2)); +} + +fn count(n: usize) -> BuildSlots { + BuildSlots::Count(std::num::NonZeroUsize::new(n).expect("n")) +} + +#[test] +fn auto_sizing_matches_memory_sizing() { + for (memory, reserved) in [ + (64u64 << 30, 0u64), + (64 << 30, 6 << 30), + (2 << 30, 0), + (19 << 30, 6 << 30), + ] { + let count = slot_count(memory, reserved); + assert_eq!( + sizing(BuildSlots::Auto, memory, reserved), + Some(Sizing { + count, + pool: slot_count(memory, 0), + extras: remote_slot_count(memory, reserved).saturating_sub(count), + }), + "{memory} {reserved}" + ); + } +} + +#[test] +fn count_sizing_keeps_the_reserve_width_above_n() { + let memory = 30u64 << 30; + assert_eq!( + sizing(count(2), memory, 0), + Some(Sizing { + count: 2, + pool: 2, + extras: 0 + }) + ); + let width = slot_count(memory, 0) - slot_count(memory, 6 << 30); + assert_eq!(width, 2); + assert_eq!( + sizing(count(2), memory, 6 << 30), + Some(Sizing { + count: 2, + pool: 2 + width, + extras: 0 + }) + ); + assert_eq!( + sizing(count(40), memory, 0), + Some(Sizing { + count: 40, + pool: 40, + extras: 0 + }), + "an explicit count is not clamped to {MAX_SLOTS}" + ); + assert_eq!(sizing(BuildSlots::Off, memory, 0), None); +} + +#[test] +fn the_default_setting_is_auto_only_inside_a_worktree_slot() { + let plain = dx_test_scratch::scratch("dx-slots-setting-"); + assert_eq!(effective_setting(None, plain.path()), BuildSlots::Off); + assert_eq!(effective_setting(Some(count(2)), plain.path()), count(2)); + let pool = dx_test_scratch::scratch("dx-slots-setting-pool-"); + let slot = pool.path().join("pool/1"); + std::fs::create_dir_all(&slot).expect("slot"); + std::fs::create_dir_all(pool.path().join("pool/.dx")).expect("meta"); + let record = dx_worktree::Record { + slot: 1, + held: false, + branch: None, + last_branch: None, + session: None, + agent: None, + pid: None, + pid_start: None, + boot_id: None, + gone: false, + stopped_at: None, + unclaimed: false, + broken: false, + leased_at: 0, + last_used: 0, + lock_digest: None, + }; + std::fs::write( + pool.path().join("pool/.dx/1.json"), + serde_json::to_vec(&record).expect("json"), + ) + .expect("record"); + assert_eq!(effective_setting(None, &slot), BuildSlots::Auto); + assert_eq!( + effective_setting(Some(BuildSlots::Off), &slot), + BuildSlots::Off + ); +} + +#[test] +fn off_and_nested_runs_build_no_gate() { + let scratch = dx_test_scratch::scratch("dx-slots-off-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let get = |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + assert!(Gate::from_env(Path::new("/ws"), &get, false, &[], BuildSlots::Off).is_none()); + let gate = Gate::from_env(Path::new("/ws"), &get, false, &[], count(3)).expect("gate"); + assert_eq!((gate.count, gate.pool, gate.extras), (3, 3, 0)); + let nested = |name: &str| match name { + "XDG_CACHE_HOME" => Some(cache.clone()), + SLOT_ENV_VAR => Some("1".to_owned()), + _ => None, + }; + for setting in [BuildSlots::Auto, BuildSlots::Off, count(1), count(40)] { + assert!( + Gate::from_env(Path::new("/ws"), &nested, false, &[], setting).is_none(), + "a child under a held slot ignores {setting}" + ); + } +} + +#[test] +fn a_count_gate_waits_on_the_take_past_n() { + let scratch = dx_test_scratch::scratch("dx-slots-count-wait-"); + let dir = scratch.path().join("slots"); + let size = sizing(count(2), 64 << 30, 0).expect("sizing"); + let gate = Gate::new(dir.clone(), size.count, "fill".to_owned(), false) + .reserving(size.pool) + .extra(size.extras, false); + let (first, _, _) = take_gate(&gate); + let (second, _, _) = take_gate(&gate); + assert_eq!((first.index, second.index), (0, 1)); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + drop(first); + let (third, err, _) = waiter.join().expect("join"); + assert_eq!(third.index, 0); + assert!( + err.starts_with("dx: waiting for a build slot (held by fill"), + "{err}" + ); + drop(second); +} + +#[test] +fn rules_dx_runs_share_the_reserve() { + let scratch = dx_test_scratch::scratch("dx-slots-share-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 2, "r".to_owned(), false).reserving(3); + let (first, first_err, _) = take_gate(&gate); + let (second, second_err, _) = take_gate(&gate); + assert_eq!((first.index, second.index), (0, 1)); + assert_eq!((first_err.as_str(), second_err.as_str()), ("", "")); + assert!(try_take(&dir, 3, "c").expect("try").is_none()); +} + +#[test] +fn a_rules_dx_run_waits_for_a_consumer_on_the_reserve() { + let scratch = dx_test_scratch::scratch("dx-slots-reserve-wait-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); + let consumer = try_take(&dir, 2, "consumer") + .expect("try") + .expect("consumer"); + assert_eq!(consumer.index, 1); + drop(fill); + let gate = Gate::new(dir.clone(), 1, "r".to_owned(), true).reserving(2); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + drop(consumer); + let (slot, err, events) = waiter.join().expect("join"); + assert_eq!(slot.index, 0); + assert_eq!(err, format!("dx: {}\n", wait_message("consumer"))); + assert_eq!( + events.matches("waiting_for_build_slot").count(), + 1, + "{events}" + ); +} + +#[test] +fn a_want_round_trips_and_garbage_wants_everything() { + let want = Want { + base: 0..3, + reserve: 3..5, + extras: 5..7, + remote: true, + }; + assert_eq!(Want::parse(&want.render()), Some(want)); + for garbage in [ + "", + "base=0..1", + "base=x", + "base=0..1 reserve=1..1 extras=1..1 remote=0 bogus=1", + ] { + assert_eq!(Want::parse(garbage), None, "{garbage}"); + } + let scratch = dx_test_scratch::scratch("dx-slots-want-garbage-"); + assert_eq!( + Want::read(&scratch.path().join("missing.want")), + Want::everything() + ); +} + +#[test] +fn a_dropped_ticket_removes_its_want_and_the_sweep_removes_orphans() { + let scratch = dx_test_scratch::scratch("dx-slots-want-drop-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(queue_dir(&dir)).expect("queue"); + let want = Want::of( + &Layout { + count: 1, + reserve: 1..2, + extras: 2..2, + }, + false, + ); + let ticket = enqueue_wanting(&dir, "", &want).expect("ticket"); + let path = want_path(&ticket.path); + assert_eq!(Want::read(&path), want); + drop(ticket); + assert!(!path.exists()); + let orphan = queue_dir(&dir).join("ticket-00000000000000000099.want"); + std::fs::write(&orphan, "x").expect("orphan"); + drop(enqueue(&dir, "").expect("ticket")); + assert!(!orphan.exists()); +} + +#[test] +fn a_waiters_label_is_kept_from_its_sidecar_and_swept_with_the_ticket() { + let scratch = dx_test_scratch::scratch("dx-slots-label-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(queue_dir(&dir)).expect("queue"); + let ticket = enqueue(&dir, "/ws/waiter").expect("ticket"); + let label = label_path(&ticket.path); + std::fs::write(&label, "/ws/sidecar").expect("sidecar"); + let kept = kept_workspaces(&dir, 0..0, "/ws/own"); + assert!(kept.contains(&PathBuf::from("/ws/sidecar"))); + assert!(label.exists()); + drop(ticket); + assert!(!label.exists()); + let orphan = queue_dir(&dir).join("ticket-00000000000000000099.label"); + std::fs::write(&orphan, "x").expect("orphan"); + drop(enqueue(&dir, "").expect("ticket")); + assert!(!orphan.exists()); +} + +#[test] +fn a_future_mtime_is_not_recent_and_not_a_live_waiter() { + let scratch = dx_test_scratch::scratch("dx-slots-future-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + let future = SystemTime::now() + Duration::from_secs(3600); + let set = |path: &Path| { + let file = File::options().write(true).open(path).expect("open"); + file.set_modified(future).expect("set mtime"); + }; + std::fs::write(extra_mark(&dir), "").expect("mark"); + assert!(extra_recent(&dir)); + set(&extra_mark(&dir)); + assert!(!extra_recent(&dir)); + let ticket = dir.join("ticket"); + std::fs::write(&ticket, "").expect("ticket"); + assert!(!waiting(&ticket)); + set(&ticket); + assert!(!waiting(&ticket)); +} + +#[test] +fn an_unwritable_queue_dir_warns_and_the_run_still_gets_a_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-queue-warn-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(queue_dir(&dir), "not a directory").expect("block queue"); + let layout = Layout { + count: 1, + reserve: 1..1, + extras: 1..1, + }; + let mut skipped = Vec::new(); + let slot = acquire_reserving( + &dir, + &layout, + Admission::none(), + "r", + &mut |_, _| {}, + &mut || {}, + &mut |path, _| skipped.push(path.to_path_buf()), + ) + .expect("slot"); + assert_eq!(slot.index, 0); + assert_eq!(skipped, vec![queue_dir(&dir)]); +} + +#[test] +fn an_unwritable_queue_warns_once_through_the_gate() { + let scratch = dx_test_scratch::scratch("dx-slots-queue-gate-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(queue_dir(&dir), "").expect("blocker"); + let gate = Gate::new(dir.clone(), 2, "a".to_owned(), false); + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = gate.take(Command::Test, &mut err, &mut events); + assert!(matches!(slot, Ok(Some(_)))); + let text = String::from_utf8(err).expect("utf8"); + assert_eq!(text.lines().count(), 1, "{text}"); + assert!( + text.contains(&queue_dir(&dir).display().to_string()), + "{text}" + ); +} + +#[test] +fn a_consumer_passes_a_rules_dx_head_blocked_on_its_reserve() { + let scratch = dx_test_scratch::scratch("dx-slots-pass-head-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); + let consumer = try_take(&dir, 2, "consumer") + .expect("try") + .expect("consumer"); + assert_eq!(consumer.index, 1); + drop(fill); + let (sender, receiver) = std::sync::mpsc::channel(); + let head_dir = dir.clone(); + let head = std::thread::spawn(move || { + let layout = Layout { + count: 1, + reserve: 1..2, + extras: 2..2, + }; + acquire_reserving( + &head_dir, + &layout, + Admission::none(), + "r", + &mut |holder, _| { + sender.send(holder.to_owned()).expect("send"); + }, + &mut || {}, + &mut |_, _| {}, + ) + .map(|slot| slot.index) + }); + receiver + .recv_timeout(Duration::from_secs(30)) + .expect("head waits"); + let behind = acquire(&dir, 2, "behind", &mut |_| {}).expect("behind passes"); + assert_eq!(behind.index, 0); + drop(behind); + drop(consumer); + assert_eq!(head.join().expect("join").expect("head"), 0); +} + +#[test] +fn a_queued_consumer_does_not_take_the_reserve_behind_a_rules_dx_run() { + let scratch = dx_test_scratch::scratch("dx-slots-reserve-line-"); + let dir = scratch.path().join("slots"); + let fill = try_take(&dir, 2, "fill").expect("try").expect("fill"); + let consumer = try_take(&dir, 2, "consumer") + .expect("try") + .expect("consumer"); + drop(fill); + let (sender, receiver) = std::sync::mpsc::channel(); + let rules_dx_dir = dir.clone(); + let rules_dx = std::thread::spawn(move || { + let layout = Layout { + count: 1, + reserve: 1..2, + extras: 2..2, + }; + acquire_reserving( + &rules_dx_dir, + &layout, + Admission::none(), + "r", + &mut |holder, _| { + sender.send(holder.to_owned()).expect("send"); + }, + &mut || {}, + &mut |_, _| {}, + ) + .expect("slot") + }); + let notice = receiver + .recv_timeout(Duration::from_secs(30)) + .expect("notice"); + assert_eq!(notice, "consumer"); + drop(consumer); + let held = rules_dx.join().expect("join"); + assert_eq!(held.index, 0); + let (sender, receiver) = std::sync::mpsc::channel(); + let late_dir = dir.clone(); + let late = std::thread::spawn(move || { + acquire(&late_dir, 2, "late", &mut |holder| { + sender.send(holder.to_owned()).expect("send"); + }) + .expect("slot") + .index + }); + let notice = receiver + .recv_timeout(Duration::from_secs(30)) + .expect("notice"); + assert_eq!(notice, "r"); + std::thread::sleep(Duration::from_millis(600)); + assert!( + !late.is_finished(), + "a consumer must not take a reserve slot" + ); + drop(held); + assert!(late.join().expect("join") < 2); +} + +#[test] +fn an_unopenable_slot_file_is_skipped() { + let scratch = dx_test_scratch::scratch("dx-slots-skip-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); + let slot = acquire(&dir, 2, "a", &mut |_| panic!("must not wait")).expect("slot"); + assert_eq!(slot.index, 1); +} + +#[cfg(unix)] +#[test] +fn read_only_slot_file_still_counts() { + use std::os::unix::fs::PermissionsExt; + let scratch = dx_test_scratch::scratch("dx-slots-readonly-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + let path = slot_path(&dir, 0); + std::fs::write(&path, b"").expect("slot file"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).expect("chmod"); + let slot = acquire(&dir, 1, "a", &mut |_| panic!("must not wait")).expect("slot"); + assert_eq!(slot.index, 0); + assert!(try_take(&dir, 1, "b").expect("try").is_none()); + drop(slot); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); +} + +#[cfg(unix)] +#[test] +fn a_read_only_reserve_slot_still_reserves() { + use std::os::unix::fs::PermissionsExt; + let scratch = dx_test_scratch::scratch("dx-slots-readonly-reserve-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + let path = slot_path(&dir, 1); + std::fs::write(&path, b"").expect("slot file"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).expect("chmod"); + let gate = Gate::new(dir.clone(), 1, "r".to_owned(), false).reserving(2); + let (slot, err, _) = take_gate(&gate); + assert_eq!((slot.index, err.as_str()), (0, "")); + assert!(try_take(&dir, 2, "c").expect("try").is_none()); + drop(slot); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); +} + +#[test] +fn a_skipped_slot_warns_once() { + for json in [true, false] { + let scratch = dx_test_scratch::scratch("dx-slots-warn-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); + let gate = Gate::new(dir, 2, "a".to_owned(), json); + let mut err = Vec::new(); + let mut events = Vec::new(); + for _ in 0..2 { + let slot = gate.clone().take(Command::Test, &mut err, &mut events); + assert!(matches!(slot, Ok(Some(ref slot)) if slot.index == 1)); + } + let text = String::from_utf8(err).expect("utf8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1, "{text}"); + assert!(lines[0].starts_with("dx: skipped build slot "), "{text}"); + assert!(lines[0].contains("slot-0.lock"), "{text}"); + let events = String::from_utf8(events).expect("utf8"); + if json { + assert_eq!(events.lines().count(), 1, "{events}"); + assert!(events.contains("\"event\":\"notice\"")); + assert!(events.contains("build_slot_skipped")); + assert!(events.contains("\"severity\":\"warning\"")); + } else { + assert!(events.is_empty(), "{events}"); + } + } +} + +#[test] +fn no_openable_slot_file_is_an_error() { + let scratch = dx_test_scratch::scratch("dx-slots-none-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); + assert!(try_take(&dir, 1, "a").is_err()); +} + +#[test] +fn a_bad_slot_and_busy_slots_still_wait() { + let scratch = dx_test_scratch::scratch("dx-slots-partial-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(slot_path(&dir, 0)).expect("blocker"); + let _held = acquire(&dir, 2, "a", &mut |_| {}).expect("held"); + assert!(try_take(&dir, 2, "b").expect("try").is_none()); +} + +#[test] +fn a_crashed_holder_frees_its_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-crash-"); + let dir = scratch.path().join("slots"); + let mut child = spawn_holder(&dir); + assert!(try_take(&dir, 1, "me").expect("try").is_none()); + child.kill().expect("kill"); + child.wait().expect("wait"); + let slot = acquire(&dir, 1, "me", &mut |_| {}).expect("slot after crash"); + assert_eq!(slot.index, 0); +} + +#[test] +fn a_nested_call_takes_no_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-nested-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let nested = |name: &str| match name { + "XDG_CACHE_HOME" => Some(cache.clone()), + SLOT_ENV_VAR => Some("1".to_owned()), + _ => None, + }; + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = hold( + Command::Build, + Path::new("/ws"), + &nested, + &mut err, + false, + &mut events, + ); + assert!(matches!(slot, Ok(None))); + assert!(!scratch.path().join("dx").exists()); +} + +#[test] +fn a_dx_call_inside_a_bazel_test_takes_no_slot_when_all_are_full() { + let scratch = dx_test_scratch::scratch("dx-slots-bazel-test-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let dir = scratch.path().join("dx").join("slots"); + let count = slot_count(available_memory().expect("memory"), 0); + let _held: Vec = (0..count) + .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("fill")) + .collect(); + let inside_test = |name: &str| match name { + "XDG_CACHE_HOME" => Some(cache.clone()), + BAZEL_TEST_ENV_VAR => Some("1".to_owned()), + _ => None, + }; + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = hold( + Command::Test, + Path::new("/ws"), + &inside_test, + &mut err, + true, + &mut events, + ); + assert!(matches!(slot, Ok(None))); + assert!(err.is_empty() && events.is_empty()); +} + +fn busy(count: usize) -> String { + let more = (1..count) + .last() + .map_or(String::new(), |n| format!(" and {n} more")); + format!("tree-0{more}") +} + +#[test] +fn waiting_prints_one_line_and_one_json_notice() { + let scratch = dx_test_scratch::scratch("dx-slots-notice-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let dir = scratch.path().join("dx").join("slots"); + let count = slot_count(available_memory().expect("memory"), 0); + let held: Vec = (0..count) + .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("fill")) + .collect(); + let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + let (mut err, rx) = Announce::new(); + let releaser = std::thread::spawn(move || released_after_wait(&rx, held)); + let mut events = Vec::new(); + let slot = hold( + Command::Test, + Path::new("/ws"), + &get, + &mut err, + true, + &mut events, + ); + assert!( + releaser.join().expect("join"), + "waiter never announced a wait" + ); + assert!(matches!(slot, Ok(Some(_)))); + let text = err.text(); + assert_eq!(text.lines().count(), 1); + assert!(text.contains(&wait_message(&busy(count))), "{text}"); + let json = String::from_utf8(events).expect("utf8"); + assert_eq!(json.lines().count(), 1); + assert!(json.contains("\"event\":\"notice\"")); + assert!(json.contains("waiting_for_build_slot")); +} + +#[test] +fn unopenable_slots_print_one_disabled_line_and_one_json_notice() { + let scratch = dx_test_scratch::scratch("dx-slots-disabled-"); + let cache = scratch.path().to_string_lossy().into_owned(); + std::fs::create_dir_all(scratch.path().join("dx")).expect("dx dir"); + std::fs::write(scratch.path().join("dx").join("slots"), b"").expect("blocker"); + let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + for json in [true, false] { + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = hold( + Command::Test, + Path::new("/ws"), + &get, + &mut err, + json, + &mut events, + ); + assert!(matches!(slot, Ok(None))); + let text = String::from_utf8(err).expect("utf8"); + assert_eq!(text.lines().count(), 1); + assert!(text.starts_with("dx: build slots disabled: "), "{text}"); + let lines = String::from_utf8(events).expect("utf8"); + if json { + assert_eq!(lines.lines().count(), 1); + assert!(lines.contains("\"event\":\"notice\"")); + assert!(lines.contains("build_slots_disabled")); + assert!(lines.contains("\"severity\":\"warning\"")); + } else { + assert!(lines.is_empty()); + } + } +} + +#[test] +fn update_holds_no_process_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-update-"); + let cache = scratch.path().to_string_lossy().into_owned(); + let dir = scratch.path().join("dx").join("slots"); + let count = slot_count(available_memory().expect("memory"), 0); + let held: Vec = (0..count) + .map(|index| acquire(&dir, count, &format!("tree-{index}"), &mut |_| {}).expect("fill")) + .collect(); + let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + let mut err = Vec::new(); + let mut events = Vec::new(); + let slot = hold( + Command::Update, + Path::new("/ws"), + &get, + &mut err, + true, + &mut events, + ); + assert!(matches!(slot, Ok(None))); + assert!(err.is_empty(), "{}", String::from_utf8_lossy(&err)); + assert!(events.is_empty(), "{}", String::from_utf8_lossy(&events)); + drop(held); +} + +#[test] +fn a_waiter_polls_while_it_waits_and_stops_once_it_takes_the_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-poll-"); + let dir = scratch.path().join("slots"); + let first = acquire(&dir, 1, "tree-a", &mut |_| {}).expect("first"); + let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = polls.clone(); + let waiter_dir = dir.clone(); + let waiter = std::thread::spawn(move || { + acquire_with( + &waiter_dir, + 1, + "tree-b", + &mut |_, _| {}, + &mut || { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }, + &mut |_, _| {}, + ) + .expect("second") + }); + let start = Instant::now(); + while polls.load(std::sync::atomic::Ordering::SeqCst) < 2 { + assert!( + start.elapsed() < Duration::from_secs(30), + "waiter never polled" + ); + std::thread::sleep(Duration::from_millis(25)); + } + drop(first); + let slot = waiter.join().expect("join"); + let after = polls.load(std::sync::atomic::Ordering::SeqCst); + assert!(after >= 2); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), after); + drop(slot); +} + +type Reaps = Arc, u64)>>>; + +fn recording(gate: Gate, stopped: usize) -> (Gate, Reaps) { + let calls: Reaps = Arc::default(); + let seen = Arc::clone(&calls); + let gate = gate.reaping(move |keep, target| { + seen.lock().expect("reaps").push((keep.to_vec(), target)); + stopped + }); + (gate, calls) +} + +/// Makes `gate` wait behind a holder, then frees the slot; returns the wait text and JSON. +fn wait_behind_holder(dir: &Path, gate: Gate) -> (String, String) { + let holder = take_gate(&Gate::new( + dir.to_path_buf(), + 1, + "/w/holder".to_owned(), + false, + )) + .0; + let (waiter, rx) = spawn_take(gate); + announced(&rx); + drop(holder); + let (_slot, err, events) = waiter.join().expect("join"); + (err, events) +} + +fn wait_notice(events: &str) -> serde_json::Value { + let notices: Vec = events + .lines() + .map(|line| serde_json::from_str(line).expect("json")) + .collect(); + assert_eq!(notices.len(), 1, "{events}"); + assert_eq!(notices[0]["code"], "waiting_for_build_slot"); + notices[0].clone() +} + +fn busy_gate(dir: &Path, wait: Duration) -> (Gate, Slot) { + let holder = take_gate(&Gate::new( + dir.to_path_buf(), + 1, + "/w/holder".to_owned(), + false, + )) + .0; + let start = Instant::now(); + let ticks = std::sync::atomic::AtomicU64::new(0); + let gate = Gate::new(dir.to_path_buf(), 1, "/w/me".to_owned(), true) + .with_clock(move || { + start + Duration::from_secs(ticks.fetch_add(1, std::sync::atomic::Ordering::SeqCst)) + }) + .waiting_at_most(Some(wait)); + (gate, holder) +} + +#[test] +fn a_zero_wait_fails_at_once_naming_the_holder() { + let scratch = dx_test_scratch::scratch("dx-slots-busy-"); + let dir = scratch.path().join("slots"); + let (gate, _holder) = busy_gate(&dir, Duration::ZERO); + let (mut err, mut events) = (Vec::new(), Vec::new()); + let stop = gate + .take(Command::Test, &mut err, &mut events) + .err() + .expect("busy"); + assert_eq!(stop, Stop::Busy("/w/holder".to_owned())); + assert!(err.is_empty() && events.is_empty()); +} + +#[test] +fn a_wait_gives_up_once_the_clock_passes_the_limit() { + let scratch = dx_test_scratch::scratch("dx-slots-deadline-"); + let dir = scratch.path().join("slots"); + let (gate, _holder) = busy_gate(&dir, Duration::from_secs(3)); + let (mut err, mut events) = (Vec::new(), Vec::new()); + let stop = gate + .take(Command::Test, &mut err, &mut events) + .err() + .expect("busy"); + assert_eq!(stop, Stop::Busy("/w/holder".to_owned())); + assert!( + String::from_utf8(err) + .expect("utf8") + .contains("waiting for a build slot") + ); +} + +#[test] +fn a_free_slot_is_taken_even_with_a_zero_wait() { + let scratch = dx_test_scratch::scratch("dx-slots-free-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir, 1, "/w/me".to_owned(), false).waiting_at_most(Some(Duration::ZERO)); + assert!(take_gate(&gate).0.index == 0); +} + +#[test] +fn a_wait_reaps_once_keeping_holders_waiters_and_itself() { + let scratch = dx_test_scratch::scratch("dx-slots-reap-"); + let dir = scratch.path().join("slots"); + let holder = take_gate(&Gate::new(dir.clone(), 1, "/w/holder".to_owned(), false)).0; + let queued = enqueue(&dir, "/w/waiter").expect("queued"); + let (gate, calls) = recording(Gate::new(dir.clone(), 1, "/w/me".to_owned(), true), 2); + let (waiter, rx) = spawn_take(gate); + announced(&rx); + drop(queued); + drop(holder); + let (_slot, err, events) = waiter.join().expect("join"); + let calls = calls.lock().expect("reaps").clone(); + assert_eq!(calls.len(), 1); + let (keep, target) = &calls[0]; + for workspace in ["/w/me", "/w/holder", "/w/waiter"] { + assert!(keep.contains(&PathBuf::from(workspace)), "{keep:?}"); + } + assert_eq!(*target, BYTES_PER_SLOT); + assert_eq!( + err.trim(), + format!( + "dx: {}; stopped 2 idle Bazel servers", + wait_message("/w/holder, 1 ahead in line") + ) + ); + let notice = wait_notice(&events); + assert_eq!(notice["reaped_servers"], 2); + assert!( + notice["message"] + .as_str() + .is_some_and(|text| text.ends_with("; stopped 2 idle Bazel servers")) + ); +} + +#[test] +fn a_wait_that_stops_nothing_keeps_the_plain_line_and_reports_zero() { + let scratch = dx_test_scratch::scratch("dx-slots-reap-none-"); + let dir = scratch.path().join("slots"); + let (gate, calls) = recording(Gate::new(dir.clone(), 1, "/w/me".to_owned(), true), 0); + let (err, events) = wait_behind_holder(&dir, gate); + assert_eq!(calls.lock().expect("reaps").len(), 1); + assert_eq!(err.trim(), format!("dx: {}", wait_message("/w/holder"))); + assert_eq!(wait_notice(&events)["reaped_servers"], 0); +} + +#[test] +fn a_gate_without_a_reaper_reports_no_reaped_servers() { + let scratch = dx_test_scratch::scratch("dx-slots-no-reaper-"); + let dir = scratch.path().join("slots"); + let gate = Gate::new(dir.clone(), 1, "/w/me".to_owned(), true); + let (err, events) = wait_behind_holder(&dir, gate); + assert_eq!(err.trim(), format!("dx: {}", wait_message("/w/holder"))); + assert!(wait_notice(&events).get("reaped_servers").is_none()); +} + +#[test] +fn another_reaping_waiter_skips_the_reap() { + let scratch = dx_test_scratch::scratch("dx-slots-reap-busy-"); + let dir = scratch.path().join("slots"); + std::fs::create_dir_all(&dir).expect("dir"); + let busy = dx_atomic_fs::lease::try_lock_file(&dir.join("reap.lock"), true) + .expect("lock") + .expect("free"); + let (gate, calls) = recording(Gate::new(dir.clone(), 1, "/w/me".to_owned(), true), 3); + let (err, events) = wait_behind_holder(&dir, gate); + drop(busy); + assert!(calls.lock().expect("reaps").is_empty()); + assert_eq!(err.trim(), format!("dx: {}", wait_message("/w/holder"))); + assert_eq!(wait_notice(&events)["reaped_servers"], 0); +} + +#[test] +fn a_free_slot_never_reaps() { + let scratch = dx_test_scratch::scratch("dx-slots-reap-free-"); + let (gate, calls) = recording( + Gate::new(scratch.path().join("slots"), 1, "/w/me".to_owned(), true), + 1, + ); + let (_slot, err, events) = take_gate(&gate); + assert!(calls.lock().expect("reaps").is_empty()); + assert!(err.is_empty() && events.is_empty()); +} + +#[test] +fn a_remote_gate_reaps_one_remote_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-reap-remote-"); + let dir = scratch.path().join("slots"); + let (gate, calls) = recording( + Gate::new(dir.clone(), 1, "/w/me".to_owned(), false).extra(0, true), + 0, + ); + wait_behind_holder(&dir, gate); + assert_eq!(calls.lock().expect("reaps")[0].1, BYTES_PER_REMOTE_SLOT); +} + +#[test] +fn reaped_suffix_counts_servers() { + assert_eq!(reaped_suffix(0), ""); + assert_eq!(reaped_suffix(1), "; stopped 1 idle Bazel server"); + assert_eq!(reaped_suffix(3), "; stopped 3 idle Bazel servers"); +} + +#[test] +fn a_free_slot_never_polls() { + let scratch = dx_test_scratch::scratch("dx-slots-nopoll-"); + let dir = scratch.path().join("slots"); + let mut polls = 0; + let slot = acquire_with( + &dir, + 1, + "tree", + &mut |_, _| {}, + &mut || polls += 1, + &mut |_, _| {}, + ) + .expect("slot"); + assert_eq!(slot.index, 0); + assert_eq!(polls, 0); +} + +#[test] +fn ping_interval_is_a_third_of_the_idle_limit_within_bounds() { + assert_eq!(ping_interval(Some(90)), Some(Duration::from_secs(30))); + assert_eq!(ping_interval(Some(300)), Some(Duration::from_secs(60))); + assert_eq!(ping_interval(Some(900)), Some(Duration::from_secs(60))); + assert_eq!(ping_interval(None), Some(Duration::from_secs(60))); + assert_eq!(ping_interval(Some(2)), Some(Duration::from_secs(1))); + assert_eq!(ping_interval(Some(0)), None); +} + +#[test] +fn idle_limit_is_read_from_the_server_cmdline() { + let cmdline = "bazel(ws)\0-jar\0/i/A-server.jar\0--max_idle_secs=90\0--output_base=/o\0"; + assert_eq!(idle_limit(cmdline), Some(90)); + assert_eq!(idle_limit("bazel(ws)\0--output_base=/o\0"), None); + assert_eq!(idle_limit("--max_idle_secs=x\0"), None); +} + +#[test] +fn startup_options_come_from_the_dx_command_line() { + let words = |list: &[&str]| list.iter().map(OsString::from).collect::>(); + assert_eq!( + own_startup_options(&words(&["test", "//..."])), + Some(Vec::new()) + ); + assert_eq!( + own_startup_options(&words(&[ + "test", + "--bazel-startup-option=--output_base=/o", + "//..." + ])), + Some(vec!["--output_base=/o".to_owned()]) + ); + assert_eq!(own_startup_options(&words(&["slots::"])), None); +} + +const BASE: &str = "0123456789abcdef0123456789abcdef"; + +fn server(pid: u32, workspace: &str, root: &str, base: &str) -> Server { + Server { + pid, + rss_bytes: 0, + workspace: Some(PathBuf::from(workspace)), + output_base: Some(PathBuf::from(base)), + output_user_root: Some(PathBuf::from(root)), + } +} + +#[test] +fn own_server_is_the_one_this_workspace_reaches() { + let roots = vec![PathBuf::from("/c/_bazel_u")]; + let base = format!("/c/_bazel_u/{BASE}"); + let servers = vec![ + server( + 1, + "/other", + "/c/_bazel_u", + "/c/_bazel_u/ffffffffffffffffffffffffffffffff", + ), + server(2, "/ws", "/c/_bazel_u", &base), + ]; + let pid = |root: Option<&str>, base: Option<&str>| { + own_server( + &servers, + Path::new("/ws"), + root.map(Path::new), + base.map(Path::new), + &roots, + ) + .map(|found| found.pid) + }; + assert_eq!(pid(None, None), Some(2)); + assert_eq!(pid(None, Some(&base)), Some(2)); + assert_eq!(pid(None, Some("/elsewhere")), None); + assert_eq!(pid(Some("/elsewhere"), None), None); + assert_eq!( + own_server(&servers, Path::new("/nope"), None, None, &roots).map(|found| found.pid), + None + ); +} + +#[test] +fn own_server_skips_servers_a_plain_call_would_not_reach() { + let roots = vec![PathBuf::from("/c/_bazel_u")]; + let custom = vec![server(3, "/ws", "/c/_bazel_u", "/c/_bazel_u/custom")]; + assert!(own_server(&custom, Path::new("/ws"), None, None, &roots).is_none()); + let other_root = vec![server(4, "/ws", "/r", &format!("/r/{BASE}"))]; + assert!(own_server(&other_root, Path::new("/ws"), None, None, &roots).is_none()); + assert_eq!( + own_server( + &other_root, + Path::new("/ws"), + Some(Path::new("/r")), + None, + &roots + ) + .map(|found| found.pid), + Some(4) + ); + let two = vec![ + server(5, "/ws", "/c/_bazel_u", &format!("/c/_bazel_u/{BASE}")), + server( + 6, + "/ws", + "/c/_bazel_u", + "/c/_bazel_u/fedcba9876543210fedcba9876543210", + ), + ]; + assert!(own_server(&two, Path::new("/ws"), None, None, &roots).is_none()); +} + +#[test] +fn own_server_follows_an_rc_root_and_base() { + let roots = vec![PathBuf::from("/c/_bazel_u")]; + let servers = vec![server(7, "/ws", "/r", "/r/custom")]; + let pid = |root: Option<&str>, base: Option<&str>| { + own_server( + &servers, + Path::new("/ws"), + root.map(Path::new), + base.map(Path::new), + &roots, + ) + .map(|found| found.pid) + }; + assert_eq!(pid(Some("/r"), Some("/r/custom")), Some(7)); + assert_eq!(pid(None, Some("/r/custom")), None); + assert_eq!(pid(Some("/r"), None), None); +} + +#[test] +fn reap_roots_add_the_rc_root_to_the_defaults() { + let defaults = vec![PathBuf::from("/c/_bazel_u")]; + let none = dx_process::rc::StartupPaths::default(); + assert_eq!(reap_roots(&defaults, &none), defaults); + let paths = dx_process::rc::StartupPaths { + output_user_root: Some(PathBuf::from("/r")), + output_base: None, + }; + assert_eq!( + reap_roots(&defaults, &paths), + vec![PathBuf::from("/c/_bazel_u"), PathBuf::from("/r")] + ); +} + +#[derive(Default)] +struct FakeLog { + lookups: usize, + pings: usize, + stopped: usize, + running: bool, + dead: bool, +} + +struct FakeWarm { + server: Option<(u32, Option)>, + log: std::rc::Rc>, +} + +impl Warm for FakeWarm { + type Ping = (); + + fn own_server(&self) -> Option<(u32, Option)> { + self.log.borrow_mut().lookups += 1; + self.server + } + + fn alive(&self, _pid: u32) -> bool { + !self.log.borrow().dead + } + + fn ping(&self) -> Option<()> { + let mut log = self.log.borrow_mut(); + assert!(!log.running, "pings overlap"); + log.pings += 1; + log.running = true; + Some(()) + } + + fn finished(&self, _ping: &mut ()) -> bool { + !self.log.borrow().running + } + + fn stop(&self, _ping: ()) { + let mut log = self.log.borrow_mut(); + log.stopped += 1; + log.running = false; + } +} + +fn keeper( + server: Option<(u32, Option)>, +) -> (KeepWarm, std::rc::Rc>) { + let log = std::rc::Rc::new(RefCell::new(FakeLog::default())); + let warm = FakeWarm { + server, + log: log.clone(), + }; + (KeepWarm::new(warm), log) +} + +#[test] +fn no_running_server_means_no_ping() { + let (mut keep, log) = keeper(None); + let start = Instant::now(); + for step in 0..10 { + keep.tick(start + Duration::from_secs(step * 100)); + } + drop(keep); + let log = log.borrow(); + assert_eq!(log.lookups, 1); + assert_eq!(log.pings, 0); + assert_eq!(log.stopped, 0); +} + +#[test] +fn a_server_that_never_idles_out_is_never_pinged() { + let (mut keep, log) = keeper(Some((7, Some(0)))); + let start = Instant::now(); + for step in 0..10 { + keep.tick(start + Duration::from_secs(step * 100)); + } + drop(keep); + let log = log.borrow(); + assert_eq!(log.lookups, 1); + assert_eq!(log.pings, 0); + assert_eq!(log.stopped, 0); +} + +#[test] +fn the_first_tick_pings_and_later_pings_wait_for_the_interval() { + let (mut keep, log) = keeper(Some((7, Some(90)))); + let start = Instant::now(); + keep.tick(start); + assert_eq!(log.borrow().pings, 1); + log.borrow_mut().running = false; + keep.tick(start + Duration::from_secs(29)); + assert_eq!(log.borrow().pings, 1); + keep.tick(start + Duration::from_secs(30)); + assert_eq!(log.borrow().pings, 2); + assert_eq!(log.borrow().lookups, 1); +} + +#[test] +fn a_running_ping_is_never_doubled() { + let (mut keep, log) = keeper(Some((7, Some(3)))); + let start = Instant::now(); + keep.tick(start); + keep.tick(start + Duration::from_secs(5)); + keep.tick(start + Duration::from_secs(10)); + assert_eq!(log.borrow().pings, 1); + log.borrow_mut().running = false; + keep.tick(start + Duration::from_secs(11)); + assert_eq!(log.borrow().pings, 2); +} + +#[test] +fn a_dead_server_stops_the_pings() { + let (mut keep, log) = keeper(Some((7, Some(3)))); + let start = Instant::now(); + keep.tick(start); + { + let mut log = log.borrow_mut(); + log.running = false; + log.dead = true; + } + keep.tick(start + Duration::from_secs(1)); + log.borrow_mut().dead = false; + keep.tick(start + Duration::from_secs(100)); + assert_eq!(log.borrow().pings, 1); +} + +#[test] +fn dropping_stops_a_running_ping() { + let (mut keep, log) = keeper(Some((7, None))); + keep.tick(Instant::now()); + assert!(log.borrow().running); + drop(keep); + assert_eq!(log.borrow().stopped, 1); + assert!(!log.borrow().running); + let (mut idle, idle_log) = keeper(Some((8, Some(900)))); + idle.tick(Instant::now()); + idle_log.borrow_mut().running = false; + idle.tick(Instant::now()); + drop(idle); + assert_eq!(idle_log.borrow().stopped, 0); +} + +/// Frees the holder's slot on its first ping, so a waiting `take` gets in on its next poll. +struct ReleasingWarm { + inner: FakeWarm, + holder: RefCell>, +} + +impl Warm for ReleasingWarm { + type Ping = (); + + fn own_server(&self) -> Option<(u32, Option)> { + self.inner.own_server() + } + + fn alive(&self, pid: u32) -> bool { + self.inner.alive(pid) + } + + fn ping(&self) -> Option<()> { + let ping = self.inner.ping(); + drop(self.holder.borrow_mut().take()); + ping + } + + fn finished(&self, ping: &mut ()) -> bool { + self.inner.finished(ping) + } + + fn stop(&self, ping: ()) { + self.inner.stop(ping); + } +} + +#[test] +fn a_waiting_take_keeps_its_server_warm_until_it_gets_a_slot() { + let scratch = dx_test_scratch::scratch("dx-slots-keepwarm-"); + let dir = scratch.path().to_path_buf(); + let (holder, _, _) = take_gate(&Gate::new(dir.clone(), 1, "/w/holder".to_owned(), false)); + let log = std::rc::Rc::new(RefCell::new(FakeLog::default())); + let warm = ReleasingWarm { + inner: FakeWarm { + server: Some((7, Some(90))), + log: log.clone(), + }, + holder: RefCell::new(Some(holder)), + }; + let waiter = Gate::new(dir, 1, "/w/waiter".to_owned(), false); + let mut err = Vec::new(); + let mut events = Vec::new(); + let got = waiter.take_warming(Command::Test, &mut err, &mut events, Some(warm)); + assert!(matches!(got, Ok(Some(_)))); + let log = log.borrow(); + assert_eq!((log.lookups, log.pings, log.stopped), (1, 1, 1)); + let err = String::from_utf8(err).expect("utf8"); + assert!(err.contains(&wait_message("/w/holder")), "{err}"); +} + +#[test] +fn a_free_slot_never_looks_for_a_server() { + let scratch = dx_test_scratch::scratch("dx-slots-nowarm-"); + let gate = Gate::new(scratch.path().to_path_buf(), 1, "/w/a".to_owned(), false); + let log = std::rc::Rc::new(RefCell::new(FakeLog::default())); + let warm = FakeWarm { + server: Some((7, Some(90))), + log: log.clone(), + }; + let got = gate.take_warming(Command::Test, &mut Vec::new(), &mut Vec::new(), Some(warm)); + assert!(matches!(got, Ok(Some(_)))); + let log = log.borrow(); + assert_eq!((log.lookups, log.pings), (0, 0)); +} + +#[test] +fn from_args_builds_warmth_from_argv_and_rc_roots() { + let scratch = dx_test_scratch::scratch("dx-slots-warmth-"); + let root = scratch.path().to_path_buf(); + let workspace = root.join("ws"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let rc_root = root.join("r"); + std::fs::write( + workspace.join(".bazelrc"), + format!("startup --output_user_root={}\n", rc_root.display()), + ) + .expect("rc"); + let cache = root.join("cache").to_string_lossy().into_owned(); + let get = move |name: &str| (name == "XDG_CACHE_HOME").then(|| cache.clone()); + let base = root.join("ob"); + let option = format!("--output_base={}", base.display()); + let args: Vec = vec![ + "test".into(), + format!("--bazel-startup-option={option}").into(), + "//...".into(), + ]; + let gate = Gate::from_args(&workspace, &get, false, &[], count(1), &args).expect("gate"); + let warm = gate.warm.expect("warmth from argv"); + assert_eq!(warm.startup_options, vec![option]); + assert_eq!(warm.output_user_root, Some(rc_root)); + assert_eq!(warm.output_base, Some(base)); + assert_eq!(warm.workspace, workspace); + + let args: Vec = vec!["slots::".into()]; + let gate = Gate::from_args(&workspace, &get, false, &[], count(1), &args).expect("gate"); + assert!( + gate.warm.is_none(), + "an argv that is not a dx command warms nothing" + ); +} + +#[test] +fn ping_argv_carries_startup_options_before_the_info_verb() { + let warmth = Warmth { + workspace: PathBuf::from("/ws"), + startup_options: vec!["--output_base=/ob".to_owned()], + output_user_root: None, + output_base: None, + default_roots: Vec::new(), + }; + let argv = warmth.ping_argv(); + let base = argv.iter().position(|arg| arg == "--output_base=/ob"); + let info = argv.iter().position(|arg| arg == "info"); + assert!(base.is_some() && base < info, "{argv:?}"); + assert_eq!( + argv[argv.len() - 3..], + ["--noblock_for_lock", "info", "server_pid"] + ); +} + +#[cfg(unix)] +#[test] +fn a_real_ping_child_is_reaped_on_stop() { + let warmth = Warmth { + workspace: std::env::temp_dir(), + startup_options: Vec::new(), + output_user_root: None, + output_base: None, + default_roots: Vec::new(), + }; + let mut child = Process::new("sleep") + .arg("30") + .stdout(Stdio::null()) + .spawn() + .expect("spawn"); + assert!(!warmth.finished(&mut child)); + let pid = i32::try_from(child.id()).expect("pid"); + warmth.stop(child); + assert_ne!(unsafe { libc::kill(pid, 0) }, 0); +} diff --git a/cli/update/BUILD.bazel b/cli/update/BUILD.bazel index 2fc476267b..6c2b0cfac5 100644 --- a/cli/update/BUILD.bazel +++ b/cli/update/BUILD.bazel @@ -87,6 +87,7 @@ rust_test( "src/accept.rs", "src/advisory.rs", "src/backend.rs", + "src/backend_tests.rs", "src/conventional.rs", "src/discover/bcr.rs", "src/discover/cabal.rs", diff --git a/cli/update/src/backend.rs b/cli/update/src/backend.rs index fd74b9ecf1..7557cc728d 100644 --- a/cli/update/src/backend.rs +++ b/cli/update/src/backend.rs @@ -1795,2471 +1795,5 @@ fn bazel(head: &dx_process::BazelHead, verb: &str, args: &[&str]) -> Vec } #[cfg(test)] -mod tests { - use super::*; - - fn ws() -> &'static Path { - Path::new("/dx-workspace") - } - - fn ws_arg() -> String { - ws().to_string_lossy().into_owned() - } - - fn dir_arg(rel: &str) -> String { - ws().join(rel).to_string_lossy().into_owned() - } - - fn no_head() -> dx_process::BazelHead { - dx_process::BazelHead::default() - } - - #[test] - fn rewrite_generated_points_nuget_repo_at_rules_dx() { - let mut run = StagedRun { - tool: StagedTool::Paket2Bazel, - argv_head: Vec::new(), - env: Vec::new(), - inputs: Vec::new(), - outputs: Vec::new(), - }; - let input = b"a\nload(\"@rules_dotnet//dotnet:defs.bzl\", \"nuget_repo\")\nb\n".to_vec(); - let want = b"a\nload(\"@rules_dx//dotnet:defs.bzl\", \"nuget_repo\")\nb\n".to_vec(); - assert_eq!(run.rewrite_generated(input.clone()), want); - run.tool = StagedTool::GoModDownload; - assert_eq!(run.rewrite_generated(input.clone()), input); - } - - #[test] - fn backend_error_message_starts_with_code() { - let set = "go".to_owned(); - for error in [ - BackendError::Unsupported { - set: set.clone(), - reason: "why", - }, - BackendError::OfflineRequired { set: set.clone() }, - BackendError::FrozenLocked { set: set.clone() }, - BackendError::BackendUnavailable { - set, - tool: "uv", - var: UV_BIN_VAR, - what: "uv", - detail: None, - }, - ] { - assert!(error.to_string().starts_with(error.code()), "{error}"); - } - } - - #[test] - fn unavailable_renders_the_bazel_detail_and_keeps_the_pin_text() { - let error = BackendError::BackendUnavailable { - set: "s".to_owned(), - tool: "uv", - var: UV_BIN_VAR, - what: "uv", - detail: Some("exit 1: boom".to_owned()), - }; - let text = error.to_string(); - assert!(text.contains("set DX_UV_BIN"), "{text}"); - assert!(text.contains("PATH is never searched"), "{text}"); - assert!( - text.ends_with("; Bazel resolution failed: exit 1: boom"), - "{text}" - ); - } - - fn configured_set(dir: &str) -> ResolvedSet { - ResolvedSet { - name: "frontend".to_owned(), - ecosystem: Ecosystem::Uv, - dir: dir.to_owned(), - manifests: vec![format!("{dir}/pyproject.toml")], - locks: vec![format!("{dir}/uv.lock")], - repository: None, - } - } - - fn closure() -> UvClosure { - UvClosure { - uv: Some(abs("uv")), - python: Some(abs("python3")), - ..UvClosure::default() - } - } - - fn abs(name: &str) -> String { - if cfg!(windows) { - format!("C:\\pinned\\{name}") - } else { - format!("/pinned/{name}") - } - } - - fn uv_argv(check: bool, offline: bool) -> Vec { - let mut argv = vec![abs("uv"), "lock".to_owned()]; - if check { - argv.push("--check".to_owned()); - } - argv.extend(strings(&[ - "--directory", - "apps/frontend", - "--no-config", - "--no-cache", - "--no-python-downloads", - "--python", - ])); - argv.push(abs("python3")); - if offline { - argv.push("--offline".to_owned()); - } - argv - } - - #[test] - fn configured_uv_plans_run_the_pinned_closure_in_the_set_directory() { - let set = configured_set("apps/frontend"); - assert_eq!( - plan_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &no_head(), - &closure() - ) - .expect("configured update"), - BackendPlan::Run { - argv: uv_argv(false, false), - env: vec![], - } - ); - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &no_head(), - &closure() - ) - .expect("configured check"), - CheckPlan::Run { - argv: uv_argv(true, false), - env: vec![], - } - ); - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - true, - false, - &no_head(), - &closure() - ) - .expect("configured check offline"), - CheckPlan::Run { - argv: uv_argv(true, true), - env: vec![], - } - ); - } - - #[test] - fn configured_uv_without_a_pinned_closure_is_unavailable() { - let set = configured_set("apps/frontend"); - let cases = [ - (UvClosure::default(), UV_BIN_VAR), - ( - UvClosure { - uv: Some("uv".to_owned()), - python: Some(abs("python3")), - ..UvClosure::default() - }, - UV_BIN_VAR, - ), - ( - UvClosure { - uv: Some(abs("uv")), - python: None, - ..UvClosure::default() - }, - UV_PYTHON_VAR, - ), - ( - UvClosure { - uv: Some(abs("uv")), - python: Some("python3".to_owned()), - ..UvClosure::default() - }, - UV_PYTHON_VAR, - ), - ]; - for (closure, var) in cases { - let error = plan_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &no_head(), - &closure, - ) - .expect_err("update needs the pinned closure"); - assert!( - matches!(error, BackendError::BackendUnavailable { var: got, .. } if got == var), - "{error:?}" - ); - let message = error.to_string(); - assert!(message.starts_with("backend_unavailable:"), "{message}"); - assert!(message.contains("cannot run uv for frontend"), "{message}"); - assert!(message.contains("frontend"), "{message}"); - assert!(message.contains(var), "{message}"); - assert!(message.contains("PATH is never searched"), "{message}"); - let error = check_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &no_head(), - &closure, - ) - .expect_err("check needs the pinned closure"); - assert!( - matches!(error, BackendError::BackendUnavailable { var: got, .. } if got == var), - "{error:?}" - ); - } - } - - #[test] - fn configured_bazel_lock_plans_a_nested_mod_deps() { - let set = bazel_set("tools", "tools/MODULE.bazel.lock"); - let head = dx_process::BazelHead { - startup: vec!["--output_base=/x".to_owned()], - options: vec!["--config=ci".to_owned()], - settings: Vec::new(), - }; - let plan = plan_configured_bazel_lock(&set, false, false, &head).expect("plan"); - assert_eq!(plan.dir, "tools"); - assert_eq!( - plan.argv[plan.argv.len() - 3..], - ["mod", "deps", "--lockfile_mode=update"] - ); - assert!(plan.argv.iter().any(|arg| arg == "--max_idle_secs=15")); - assert!(!plan.argv.iter().any(|arg| arg.contains("--output_base"))); - assert!(!plan.argv.iter().any(|arg| arg.contains("--config"))); - } - - #[test] - fn configured_bazel_lock_at_the_root_uses_the_head() { - let set = bazel_set(".", "MODULE.bazel.lock"); - let plan = plan_configured_bazel_lock(&set, false, false, &no_head()).expect("plan"); - assert_eq!(plan.dir, "."); - assert_eq!( - plan.argv[plan.argv.len() - 3..], - ["mod", "deps", "--lockfile_mode=update"] - ); - } - - #[test] - fn configured_bazel_lock_refuses_frozen_offline_and_foreign_locks() { - let set = bazel_set("tools", "tools/MODULE.bazel.lock"); - assert!(matches!( - plan_configured_bazel_lock(&set, false, true, &no_head()), - Err(BackendError::FrozenLocked { .. }) - )); - assert!(matches!( - plan_configured_bazel_lock(&set, true, false, &no_head()), - Err(BackendError::OfflineRequired { .. }) - )); - let foreign = bazel_set("tools", "other/MODULE.bazel.lock"); - assert!(matches!( - plan_configured_bazel_lock(&foreign, false, false, &no_head()), - Err(BackendError::Unsupported { .. }) - )); - } - - fn ruby_set() -> ResolvedSet { - ResolvedSet { - name: "bundle".to_owned(), - ecosystem: Ecosystem::Ruby, - dir: "app".to_owned(), - manifests: vec!["app/Gemfile".to_owned()], - locks: vec!["app/Gemfile.lock".to_owned()], - repository: None, - } - } - - #[test] - fn ruby_lock_runs_bundler_from_the_toolchain_in_the_gemfile_dir() { - let plan = plan_ruby_lock(&ruby_set(), "rake", false, false, &no_head()).expect("plan"); - assert_eq!(plan.dir, "app"); - assert!(plan.env.is_empty()); - let tail = plan.argv.iter().position(|arg| arg == "@ruby//:bundle"); - let tail = &plan.argv[tail.expect("bundle target")..]; - assert_eq!( - tail, - [ - "@ruby//:bundle", - "--", - "lock", - "--update", - "rake", - "--conservative" - ] - ); - } - - #[test] - fn ruby_lock_refuses_frozen_and_offline() { - assert!(matches!( - plan_ruby_lock(&ruby_set(), "rake", false, true, &no_head()), - Err(BackendError::FrozenLocked { .. }) - )); - assert!(matches!( - plan_ruby_lock(&ruby_set(), "rake", true, false, &no_head()), - Err(BackendError::OfflineRequired { .. }) - )); - } - - fn composer_set(locks: &[&str]) -> ResolvedSet { - ResolvedSet { - name: "php".to_owned(), - ecosystem: Ecosystem::Composer, - dir: "php".to_owned(), - manifests: vec!["php/composer.json".to_owned()], - locks: locks.iter().map(|lock| (*lock).to_owned()).collect(), - repository: None, - } - } - - fn composer_update( - set: &ResolvedSet, - offline: bool, - frozen: bool, - ) -> Result { - plan_configured( - ws(), - set, - &Selection::Packages(vec!["psr/log".to_owned()]), - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - } - - const COMPOSER_LOCKS: [&str; 2] = ["php/composer.lock", "php/composer.digests.json"]; - - #[test] - fn composer_update_runs_composer_then_the_digest_refresh() { - let set = composer_set(&COMPOSER_LOCKS); - let BackendPlan::Sequence(runs) = composer_update(&set, false, false).expect("plan") else { - panic!("expected a sequence"); - }; - assert_eq!(runs.len(), 2); - assert_eq!( - runs[0].argv[runs[0].argv.len() - 6..], - [ - "@rules_dx//php/tools:composer", - "--", - "update", - "psr/log", - "--with-all-dependencies", - "--no-install", - ] - ); - assert_eq!(runs[0].env[0].0, "COMPOSER_HOME"); - assert_eq!(runs[0].env[1].0, "COMPOSER_CACHE_DIR"); - assert_eq!( - runs[1].argv[runs[1].argv.len() - 4..], - [ - "@rules_dx//php/tools/digests:refresh_digests", - "--", - "composer.lock", - "composer.digests.json" - ] - ); - } - - #[test] - fn composer_update_without_digests_runs_only_composer() { - let set = composer_set(&["php/composer.lock"]); - let BackendPlan::Sequence(runs) = composer_update(&set, false, false).expect("plan") else { - panic!("expected a sequence"); - }; - assert_eq!(runs.len(), 1); - } - - #[test] - fn composer_update_refuses_frozen_and_offline() { - let set = composer_set(&COMPOSER_LOCKS); - assert!(matches!( - composer_update(&set, true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - composer_update(&set, false, true), - Err(BackendError::FrozenLocked { .. }) - )); - } - - #[test] - fn composer_check_validates_the_lock() { - let set = composer_set(&COMPOSER_LOCKS); - let plan = check_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("plan"); - let CheckPlan::Run { argv, env } = plan else { - panic!("expected a run"); - }; - assert_eq!( - argv[argv.len() - 5..], - [ - "@rules_dx//php/tools:composer", - "--", - "validate", - "--check-lock", - "--no-check-publish" - ] - ); - assert_eq!(env[0].0, "COMPOSER_HOME"); - } - - #[test] - fn composer_check_refuses_a_package_selection() { - let set = composer_set(&COMPOSER_LOCKS); - let error = check_configured( - ws(), - &set, - &Selection::Packages(vec!["psr/log".to_owned()]), - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect_err("refused"); - assert!(error.to_string().contains("whole composer.lock")); - } - - fn cpan_set() -> ResolvedSet { - ResolvedSet { - name: "perl".to_owned(), - ecosystem: Ecosystem::Cpan, - dir: "app".to_owned(), - manifests: vec!["app/cpanfile".to_owned()], - locks: vec![ - "app/cpanfile.snapshot".to_owned(), - "app/cpanfile.snapshot.lock.json".to_owned(), - ], - repository: None, - } - } - - fn cpan_workspace(build: &str) -> dx_test_scratch::TempDir { - let dir = dx_test_scratch::scratch("cpan_backend"); - dx_test_scratch::write(dir.path(), "app/BUILD.bazel", build); - dir - } - - const COMPILER_BUILD: &str = "cpan_compiler(name = \"compiler\", cpanfile = \"cpanfile\", lockfile = \"cpanfile.snapshot.lock.json\")\n"; - - fn cpan_update( - workspace: &Path, - offline: bool, - frozen: bool, - ) -> Result { - plan_configured( - workspace, - &cpan_set(), - &Selection::Packages(vec!["Try::Tiny".to_owned()]), - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - } - - #[test] - fn cpan_update_runs_carton_then_the_compiler() { - let dir = cpan_workspace(COMPILER_BUILD); - let BackendPlan::Sequence(runs) = cpan_update(dir.path(), false, false).expect("plan") - else { - panic!("expected a sequence"); - }; - assert_eq!(runs.len(), 2); - let carton = &runs[0].argv; - let tail = carton - .iter() - .position(|arg| arg.starts_with("--run_under=")); - assert_eq!( - carton[tail.expect("run_under") + 1..], - ["@rules_dx//perl/tools:carton", "--", "update", "Try::Tiny"] - ); - assert_eq!(runs[0].env[0].0, "PERL_CARTON_PATH"); - assert_eq!( - runs[1].argv[runs[1].argv.len() - 3..], - ["//app:compiler", "--", "--incremental"] - ); - } - - #[test] - fn cpan_update_refuses_without_a_compiler() { - let dir = cpan_workspace(""); - let error = cpan_update(dir.path(), false, false).expect_err("refused"); - assert!( - error - .to_string() - .contains("add a cpan_compiler next to the cpanfile") - ); - } - - #[test] - fn cpan_update_refuses_frozen_and_offline() { - let dir = cpan_workspace(COMPILER_BUILD); - assert!(matches!( - cpan_update(dir.path(), true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - cpan_update(dir.path(), false, true), - Err(BackendError::FrozenLocked { .. }) - )); - } - - #[test] - fn cpan_check_installs_the_snapshot_in_deployment_mode() { - let plan = check_configured( - ws(), - &cpan_set(), - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("plan"); - let CheckPlan::Run { argv, .. } = plan else { - panic!("expected a run"); - }; - assert_eq!(argv[argv.len() - 2..], ["install", "--deployment"]); - assert!(matches!( - check_configured( - ws(), - &cpan_set(), - &Selection::Full, - true, - false, - &no_head(), - &UvClosure::default() - ), - Err(BackendError::OfflineRequired { .. }) - )); - } - - fn julia_set() -> ResolvedSet { - ResolvedSet { - name: "julia".to_owned(), - ecosystem: Ecosystem::Julia, - dir: "app".to_owned(), - manifests: vec!["app/Project.toml".to_owned()], - locks: vec![ - "app/Manifest.toml".to_owned(), - "app/Manifest.bazel.json".to_owned(), - ], - repository: None, - } - } - - const JULIA_BUILD: &str = "julia_pkg_resolve(name = \"resolve\")\njulia_pkg_resolve_test(name = \"resolve_test\", resolver = \":resolve\")\n"; - - fn julia_workspace(build: &str) -> dx_test_scratch::TempDir { - let dir = dx_test_scratch::scratch("julia_backend"); - dx_test_scratch::write(dir.path(), "app/BUILD.bazel", build); - dir - } - - fn julia_update( - workspace: &Path, - request: Selection, - offline: bool, - frozen: bool, - ) -> Result { - plan_configured( - workspace, - &julia_set(), - &request, - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - } - - #[test] - fn julia_update_runs_pkg_update_then_the_resolver() { - let dir = julia_workspace(JULIA_BUILD); - let request = Selection::Packages(vec!["JSON".to_owned()]); - let BackendPlan::Sequence(runs) = - julia_update(dir.path(), request, false, false).expect("plan") - else { - panic!("expected a sequence"); - }; - assert_eq!(runs.len(), 2); - let app = dir.path().join("app").to_string_lossy().into_owned(); - assert_eq!( - runs[0].argv[runs[0].argv.len() - 4..], - [ - "@rules_dx//julia/tools:pkg_update", - "--", - app.as_str(), - "JSON" - ] - ); - assert_eq!(runs[0].env[0].0, "JULIA_DEPOT_PATH"); - assert_eq!( - runs[1].argv.last().map(String::as_str), - Some("//app:resolve") - ); - } - - #[test] - fn julia_full_update_names_no_package() { - let dir = julia_workspace(JULIA_BUILD); - let BackendPlan::Sequence(runs) = - julia_update(dir.path(), Selection::Full, false, false).expect("plan") - else { - panic!("expected a sequence"); - }; - assert_eq!( - runs[0].argv.last().map(String::as_str), - dir.path().join("app").to_str() - ); - } - - #[test] - fn julia_update_refuses_without_a_resolver_frozen_and_offline() { - let bare = julia_workspace(""); - let error = julia_update(bare.path(), Selection::Full, false, false).expect_err("refused"); - assert!(error.to_string().contains("add a julia_pkg_resolve")); - let dir = julia_workspace(JULIA_BUILD); - assert!(matches!( - julia_update(dir.path(), Selection::Full, true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - julia_update(dir.path(), Selection::Full, false, true), - Err(BackendError::FrozenLocked { .. }) - )); - } - - #[test] - fn julia_check_runs_the_resolve_test() { - let check = |workspace: &Path, request: &Selection, offline: bool| { - check_configured( - workspace, - &julia_set(), - request, - offline, - false, - &no_head(), - &UvClosure::default(), - ) - }; - let dir = julia_workspace(JULIA_BUILD); - let CheckPlan::Run { argv, .. } = check(dir.path(), &Selection::Full, false).expect("plan") - else { - panic!("expected a run"); - }; - assert_eq!(argv.last().map(String::as_str), Some("//app:resolve_test")); - let bare = julia_workspace(""); - let error = check(bare.path(), &Selection::Full, false).expect_err("refused"); - assert!(error.to_string().contains("add a julia_pkg_resolve_test")); - assert!(matches!( - check(dir.path(), &Selection::Full, true), - Err(BackendError::OfflineRequired { .. }) - )); - } - - fn ansible_set() -> ResolvedSet { - ResolvedSet { - name: "cols".to_owned(), - ecosystem: Ecosystem::Ansible, - dir: ".".to_owned(), - manifests: vec!["MODULE.bazel".to_owned()], - locks: vec!["play/lock.json".to_owned()], - repository: None, - } - } - - #[test] - fn ansible_update_runs_the_lock_target_per_collection() { - let dir = dx_test_scratch::scratch("ansible_backend"); - dx_test_scratch::write(dir.path(), "play/BUILD.bazel", "ansible_lock(name = \"lock\")\n"); - dx_test_scratch::write( - dir.path(), - "play/lock.json", - "{\"collections\": [{\"name\": \"a.one\"}, {\"name\": \"b.two\"}]}", - ); - let plan = |request: &Selection, offline: bool| { - plan_configured( - dir.path(), - &ansible_set(), - request, - offline, - false, - &no_head(), - &UvClosure::default(), - ) - }; - let BackendPlan::Sequence(runs) = plan(&Selection::Full, false).expect("plan") else { - panic!("expected a sequence"); - }; - let tails: Vec<_> = runs - .iter() - .map(|run| run.argv[run.argv.len() - 3..].join(" ")) - .collect(); - assert_eq!(tails, ["//play:lock -- a.one", "//play:lock -- b.two"]); - let BackendPlan::Sequence(one) = - plan(&Selection::Packages(vec!["c.three".to_owned()]), false).expect("plan") - else { - panic!("expected a sequence"); - }; - assert_eq!(one.len(), 1); - assert!(matches!( - plan(&Selection::Full, true), - Err(BackendError::OfflineRequired { .. }) - )); - let bare = dx_test_scratch::scratch("ansible_backend_bare"); - let error = plan_configured( - bare.path(), - &ansible_set(), - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect_err("refused"); - assert!(error.to_string().contains("add an ansible_lock")); - } - - fn cabal_set() -> ResolvedSet { - ResolvedSet { - name: "hackage".to_owned(), - ecosystem: Ecosystem::Cabal, - dir: "hs".to_owned(), - manifests: vec!["hs/cabal.project".to_owned()], - locks: vec![ - "hs/cabal.project.freeze".to_owned(), - "hs/haskell_deps.json".to_owned(), - ], - repository: None, - } - } - - const CABAL_BUILD: &str = "haskell_deps_resolve(name = \"resolve\")\nhaskell_deps_resolve_test(name = \"resolve_test\")\n"; - - #[test] - fn cabal_update_runs_the_resolve_target() { - let dir = dx_test_scratch::scratch("cabal_backend"); - dx_test_scratch::write(dir.path(), "hs/BUILD.bazel", CABAL_BUILD); - let plan = |request: &Selection, offline: bool, frozen: bool| { - plan_configured( - dir.path(), - &cabal_set(), - request, - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - }; - let BackendPlan::Run { argv, .. } = plan(&Selection::Full, false, false).expect("plan") - else { - panic!("expected a run"); - }; - assert_eq!(argv.last().map(String::as_str), Some("//hs:resolve")); - assert!(matches!( - plan(&Selection::Full, true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - plan(&Selection::Full, false, true), - Err(BackendError::FrozenLocked { .. }) - )); - assert!(plan(&Selection::Packages(vec!["safe".to_owned()]), false, false).is_err()); - } - - #[test] - fn cabal_check_runs_the_resolve_test() { - let dir = dx_test_scratch::scratch("cabal_backend_check"); - dx_test_scratch::write(dir.path(), "hs/BUILD.bazel", CABAL_BUILD); - let CheckPlan::Run { argv, .. } = check_configured( - dir.path(), - &cabal_set(), - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("plan") else { - panic!("expected a run"); - }; - assert_eq!(argv.last().map(String::as_str), Some("//hs:resolve_test")); - let bare = dx_test_scratch::scratch("cabal_backend_bare"); - let error = check_configured( - bare.path(), - &cabal_set(), - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect_err("refused"); - assert!( - error - .to_string() - .contains("add a haskell_deps_resolve_test") - ); - } - - fn swift_set() -> ResolvedSet { - ResolvedSet { - name: "swift".to_owned(), - ecosystem: Ecosystem::SwiftPm, - dir: "app".to_owned(), - manifests: vec!["app/Package.swift".to_owned()], - locks: vec!["app/Package.resolved".to_owned()], - repository: None, - } - } - - fn opam_set() -> ResolvedSet { - ResolvedSet { - name: "ocaml".to_owned(), - ecosystem: Ecosystem::Opam, - dir: "app".to_owned(), - manifests: vec!["app/app.opam".to_owned()], - locks: vec!["app/app.opam.locked".to_owned()], - repository: None, - } - } - - fn swift_update( - packages: &[&str], - offline: bool, - frozen: bool, - ) -> Result { - plan_configured( - ws(), - &swift_set(), - &Selection::Packages(strings(packages)), - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - } - - fn opam_update(offline: bool, frozen: bool) -> Result { - plan_configured( - ws(), - &opam_set(), - &Selection::Packages(vec!["astring".to_owned()]), - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - } - - #[test] - fn swift_check_resolves_in_a_copy_and_compares() { - let plan = check_configured( - ws(), - &swift_set(), - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("plan"); - let CheckPlan::Run { argv, .. } = plan else { - panic!("expected a run"); - }; - assert!(argv.iter().any(|arg| arg.starts_with("--run_under=sh -c") - && arg.ends_with(&format!("sh '{}' check", dir_arg("app"))))); - assert_eq!( - argv[argv.len() - 4..], - ["@rules_dx//swift/tools:swift", "--", "package", "resolve"] - ); - for (request, offline) in [ - (Selection::Full, true), - (Selection::Packages(strings(&["a"])), false), - ] { - assert!( - check_configured( - ws(), - &swift_set(), - &request, - offline, - false, - &no_head(), - &UvClosure::default() - ) - .is_err() - ); - } - } - - #[test] - fn swift_update_names_identities_and_refuses_frozen_and_offline() { - let Ok(BackendPlan::Run { argv, .. }) = - swift_update(&["github.com/vapor/Vapor"], false, false) - else { - panic!("expected a run"); - }; - assert!( - argv.iter() - .any(|arg| arg.ends_with(&format!("sh '{}' apply", dir_arg("app")))) - ); - assert_eq!(argv[argv.len() - 3..], ["package", "update", "vapor"]); - assert!(matches!( - swift_update(&["a"], true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - swift_update(&["a"], false, true), - Err(BackendError::FrozenLocked { .. }) - )); - } - - #[test] - fn opam_update_upgrades_then_relocks() { - let BackendPlan::Sequence(runs) = opam_update(false, false).expect("plan") else { - panic!("expected a sequence"); - }; - assert_eq!(runs.len(), 2); - assert_eq!( - runs[0].argv[runs[0].argv.len() - 2..], - ["upgrade", "astring"] - ); - assert_eq!(runs[1].argv[runs[1].argv.len() - 2..], ["lock", "app.opam"]); - assert!( - runs[0] - .argv - .iter() - .any(|a| a.contains("cd '/dx-workspace/app'")) - ); - assert!( - runs[1] - .argv - .contains(&"@rules_dx//ocaml/tools:opam".to_owned()) - ); - assert_eq!(runs[0].env[0].0, "OPAMROOT"); - } - - #[test] - fn opam_update_refuses_frozen_and_offline() { - assert!(matches!( - opam_update(false, true), - Err(BackendError::FrozenLocked { .. }) - )); - assert!(matches!( - opam_update(true, false), - Err(BackendError::OfflineRequired { .. }) - )); - } - - #[test] - fn opam_check_dry_runs_the_locked_install() { - let plan = check_configured( - ws(), - &opam_set(), - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("plan"); - let CheckPlan::Run { argv, .. } = plan else { - panic!("expected a run"); - }; - assert_eq!( - argv[argv.len() - 6..], - [ - "install", - "--locked", - "--deps-only", - "--dry-run", - "--show-actions", - "." - ] - ); - assert!(matches!( - check_configured( - ws(), - &opam_set(), - &Selection::Full, - true, - false, - &no_head(), - &UvClosure::default() - ), - Err(BackendError::OfflineRequired { .. }) - )); - } - - fn pub_set() -> ResolvedSet { - ResolvedSet { - name: "dart_deps".to_owned(), - ecosystem: Ecosystem::Pub, - dir: "app".to_owned(), - manifests: vec!["app/pubspec.yaml".to_owned()], - locks: vec!["app/pubspec.lock".to_owned()], - repository: None, - } - } - - fn pub_workspace(pubspec: &str) -> dx_test_scratch::TempDir { - let dir = dx_test_scratch::scratch("pub_backend"); - dx_test_scratch::write(dir.path(), "app/pubspec.yaml", pubspec); - dir - } - - fn pub_plan( - workspace: &Path, - request: &Selection, - offline: bool, - frozen: bool, - ) -> Result { - plan_configured( - workspace, - &pub_set(), - request, - offline, - frozen, - &no_head(), - &UvClosure::default(), - ) - } - - #[test] - fn pub_update_runs_dart_pub_upgrade() { - let dir = pub_workspace("name: app\n"); - let BackendPlan::Run { argv, env } = pub_plan( - dir.path(), - &Selection::Packages(vec!["http".to_owned()]), - false, - false, - ) - .expect("plan") else { - panic!("expected a run"); - }; - assert_eq!(argv[argv.len() - 4..], ["--", "pub", "upgrade", "http"]); - assert!(argv.contains(&"@rules_dx//dart/tools:dart".to_owned())); - assert_eq!(env[0].0, "PUB_CACHE"); - let BackendPlan::Run { argv, .. } = - pub_plan(dir.path(), &Selection::Full, false, false).expect("plan") - else { - panic!("expected a run"); - }; - assert_eq!(argv[argv.len() - 2..], ["pub", "upgrade"]); - } - - #[test] - fn pub_update_refuses_frozen_and_offline() { - let dir = pub_workspace("name: app\n"); - assert!(matches!( - pub_plan(dir.path(), &Selection::Full, true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - pub_plan(dir.path(), &Selection::Full, false, true), - Err(BackendError::FrozenLocked { .. }) - )); - let flutter = pub_workspace(FLUTTER_PUBSPEC); - assert!(matches!( - pub_plan(flutter.path(), &Selection::Full, true, false), - Err(BackendError::OfflineRequired { .. }) - )); - assert!(matches!( - pub_plan(flutter.path(), &Selection::Full, false, true), - Err(BackendError::FrozenLocked { .. }) - )); - } - - const FLUTTER_PUBSPEC: &str = "dependencies:\n flutter:\n sdk: flutter\n"; - - #[test] - fn flutter_pub_update_runs_the_rules_flutter_pub_target() { - let dir = pub_workspace(FLUTTER_PUBSPEC); - let BackendPlan::Run { argv, env } = pub_plan( - dir.path(), - &Selection::Packages(vec!["http".to_owned()]), - false, - false, - ) - .expect("plan") else { - panic!("expected a run"); - }; - let position = argv - .iter() - .position(|arg| arg == "@rules_flutter//flutter:pub") - .expect("flutter pub target"); - let app = dir.path().join("app").to_string_lossy().into_owned(); - assert_eq!( - argv[position + 1..], - ["--", "pub", "upgrade", "http", "--directory", app.as_str()] - ); - assert_eq!(env[0].0, "PUB_CACHE"); - } - - #[test] - fn flutter_pub_check_enforces_the_lockfile() { - let dir = pub_workspace(FLUTTER_PUBSPEC); - let check = |offline| { - check_configured( - dir.path(), - &pub_set(), - &Selection::Full, - offline, - false, - &no_head(), - &UvClosure::default(), - ) - }; - let Ok(CheckPlan::Run { argv, .. }) = check(true) else { - panic!("expected a run"); - }; - let position = argv - .iter() - .position(|arg| arg == "@rules_flutter//flutter:pub") - .expect("flutter pub target"); - let app = dir.path().join("app").to_string_lossy().into_owned(); - assert_eq!( - argv[position + 1..], - [ - "--", - "pub", - "get", - "--enforce-lockfile", - "--offline", - "--directory", - app.as_str() - ] - ); - } - - #[test] - fn pub_check_enforces_the_lockfile() { - let dir = pub_workspace("name: app\n"); - let check = |offline| { - check_configured( - dir.path(), - &pub_set(), - &Selection::Full, - offline, - false, - &no_head(), - &UvClosure::default(), - ) - }; - let Ok(CheckPlan::Run { argv, .. }) = check(false) else { - panic!("expected a run"); - }; - assert_eq!(argv[argv.len() - 3..], ["pub", "get", "--enforce-lockfile"]); - let Ok(CheckPlan::Run { argv, .. }) = check(true) else { - panic!("expected a run"); - }; - assert_eq!(argv[argv.len() - 1], "--offline"); - } - - fn bazel_set(dir: &str, lock: &str) -> ResolvedSet { - ResolvedSet { - name: "bazel-modules".to_owned(), - ecosystem: Ecosystem::Bazel, - dir: dir.to_owned(), - manifests: vec![format!("{dir}/MODULE.bazel")], - locks: vec![lock.to_owned()], - repository: None, - } - } - - #[test] - fn configured_bazel_selective_points_at_update() { - let set = ResolvedSet { - name: "bazel-modules".to_owned(), - ecosystem: Ecosystem::Bazel, - dir: "tools".to_owned(), - manifests: vec!["tools/MODULE.bazel".to_owned()], - locks: vec!["tools/MODULE.bazel.lock".to_owned()], - repository: None, - }; - let request = Selection::Packages(vec!["rules_cc".to_owned()]); - let planned = plan_configured(ws(), &set, &request, false, false, &no_head(), &closure()) - .expect_err("bazel pins are not resolved"); - let checked = check_configured(ws(), &set, &request, false, false, &no_head(), &closure()) - .expect_err("bazel pins are not checked per module"); - for error in [planned, checked] { - assert!( - matches!( - error, - BackendError::Unsupported { ref set, reason } - if set == "bazel-modules" - && reason.contains("dx update : ") - && reason.contains("bazel mod deps") - ), - "{error:?}" - ); - } - } - - fn pinned_sets() -> Vec<(ResolvedSet, &'static str, &'static str)> { - vec![ - ( - ResolvedSet { - name: "gems".to_owned(), - ecosystem: Ecosystem::Ruby, - dir: "apps/rb".to_owned(), - manifests: vec!["apps/rb/Gemfile".to_owned()], - locks: vec!["apps/rb/Gemfile.lock".to_owned()], - repository: None, - }, - "rspec-core", - "`bundle lock`", - ), - ( - ResolvedSet { - name: "modules".to_owned(), - ecosystem: Ecosystem::PowerShell, - dir: "tools/ps".to_owned(), - manifests: vec!["tools/ps/PSGallery.requirements.psd1".to_owned()], - locks: vec!["tools/ps/PSGallery.lock.json".to_owned()], - repository: None, - }, - "Pester", - "PSGallery.lock.json by hand", - ), - ( - ResolvedSet { - name: "rocks".to_owned(), - ecosystem: Ecosystem::LuaRocks, - dir: "apps/lua".to_owned(), - manifests: vec!["apps/lua/luarocks.lock".to_owned()], - locks: vec![ - "apps/lua/luarocks.lock".to_owned(), - "apps/lua/luarocks.digests.json".to_owned(), - ], - repository: None, - }, - "inspect", - "edit luarocks.lock and luarocks.digests.json by hand", - ), - ] - } - - #[test] - fn configured_pinned_sets_never_run_and_check_as_pinned_under_every_flag() { - for (set, _, _) in pinned_sets() { - for offline in [false, true] { - for frozen in [false, true] { - assert_eq!( - plan_configured( - ws(), - &set, - &Selection::Full, - offline, - frozen, - &no_head(), - &closure() - ), - Ok(BackendPlan::Noop), - "{} offline={offline} frozen={frozen}", - set.name - ); - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - offline, - frozen, - &no_head(), - &UvClosure::default() - ), - Ok(CheckPlan::Pinned), - "{} offline={offline} frozen={frozen}", - set.name - ); - } - } - } - } - - #[test] - fn configured_pinned_selective_names_the_lock_to_regenerate() { - for (set, package, regenerate) in pinned_sets() { - let request = Selection::Packages(vec![package.to_owned()]); - let planned = - plan_configured(ws(), &set, &request, false, false, &no_head(), &closure()) - .expect_err("pinned sets resolve nothing"); - let checked = - check_configured(ws(), &set, &request, false, false, &no_head(), &closure()) - .expect_err("pinned sets check nothing per package"); - let manifest = set.manifests[0].rsplit('/').next().expect("file"); - let lock = set.locks[0].rsplit('/').next().expect("file"); - for error in [planned, checked] { - assert!( - matches!( - error, - BackendError::Unsupported { set: ref name, reason } - if *name == set.name - && reason.contains(manifest) - && reason.contains(lock) - && reason.contains(regenerate) - ), - "{error:?}" - ); - assert_eq!(error.code(), "unsupported"); - } - } - } - - #[test] - fn configured_uv_selective_and_offline_update_fail_closed() { - let set = configured_set("apps/frontend"); - let error = plan_configured( - ws(), - &set, - &Selection::Packages(vec!["anyio".to_owned()]), - false, - false, - &no_head(), - &closure(), - ) - .expect_err("configured selective stays unsupported"); - assert!( - matches!(error, BackendError::Unsupported { .. }), - "{error:?}" - ); - assert!(error.to_string().contains("frontend")); - let error = check_configured( - ws(), - &set, - &Selection::Packages(vec!["anyio".to_owned()]), - false, - false, - &no_head(), - &closure(), - ) - .expect_err("configured selective check stays unsupported"); - assert!( - matches!(error, BackendError::Unsupported { .. }), - "{error:?}" - ); - let error = plan_configured( - ws(), - &set, - &Selection::Full, - true, - false, - &no_head(), - &closure(), - ) - .expect_err("offline update needs network"); - assert!( - matches!(error, BackendError::OfflineRequired { .. }), - "{error:?}" - ); - assert!(error.to_string().contains("offline_required")); - } - - #[test] - fn frozen_configured_update_is_locked_while_check_passes_through() { - let set = configured_set("apps/frontend"); - let error = plan_configured( - ws(), - &set, - &Selection::Full, - false, - true, - &no_head(), - &closure(), - ) - .expect_err("configured update frozen"); - assert!( - matches!(error, BackendError::FrozenLocked { .. }), - "{error:?}" - ); - assert!(error.to_string().contains("frozen_locked"), "{error}"); - assert!(error.to_string().contains("frontend"), "{error}"); - let checked = check_configured( - ws(), - &set, - &Selection::Full, - false, - true, - &no_head(), - &closure(), - ) - .expect("configured check frozen"); - assert!( - matches!(checked, CheckPlan::Run { .. }), - "uv lock --check verifies without resolving: {checked:?}" - ); - } - - fn npm_set(name: &str, dir: &str) -> ResolvedSet { - ResolvedSet { - name: name.to_owned(), - ecosystem: Ecosystem::Npm, - dir: dir.to_owned(), - manifests: vec![format!("{dir}/package.json")], - locks: vec![format!("{dir}/pnpm-lock.yaml")], - repository: None, - } - } - - fn npm_env_expected() -> Vec<(String, String)> { - let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; - vec![ - ("npm_config_globalconfig".to_owned(), null.to_owned()), - ("npm_config_userconfig".to_owned(), null.to_owned()), - ] - } - - fn npm_run( - head: &dx_process::BazelHead, - run: &[&str], - dir: &str, - command: &[&str], - ) -> Vec { - [ - head.argv("run"), - strings(run), - strings(&["@pnpm//:pnpm", "--", "--dir"]), - vec![dir_arg(dir)], - strings(command), - ] - .concat() - } - - #[cfg(unix)] - #[test] - fn non_utf8_set_directory_is_refused_before_any_tool_runs() { - use std::os::unix::ffi::OsStrExt; - let workspace = Path::new(std::ffi::OsStr::from_bytes(b"/ws\xff")); - let set = npm_set("frontend", "apps/web"); - let err = plan_configured( - workspace, - &set, - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect_err("non-UTF-8 dir"); - assert_eq!(err.code(), "invalid_path"); - assert!(err.to_string().contains("/ws\u{fffd}/apps/web"), "{err}"); - } - - #[test] - fn configured_npm_update_runs_the_consumer_pnpm_in_the_set_directory() { - let set = npm_set("frontend", "apps/web"); - let head = dx_process::BazelHead { - startup: vec!["--output_base=/ob".to_owned()], - options: vec![], - settings: vec![], - }; - let planned = plan_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &head, - &UvClosure::default(), - ) - .expect("npm update"); - assert_eq!( - planned, - BackendPlan::Run { - argv: npm_run(&head, &[], "apps/web", &["install", "--lockfile-only"]), - env: npm_env_expected(), - } - ); - let BackendPlan::Run { argv, .. } = planned else { - panic!("npm update runs pnpm"); - }; - assert!(runs_bazel(&argv)); - assert!(argv.contains(&"--output_base=/ob".to_owned()), "{argv:?}"); - let planned = plan_configured( - ws(), - &set, - &Selection::Packages(vec!["@types/node".to_owned(), "react".to_owned()]), - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("npm selective update"); - assert_eq!( - planned, - BackendPlan::Run { - argv: npm_run( - &no_head(), - &[], - "apps/web", - &["update", "@types/node", "react", "--lockfile-only"] - ), - env: npm_env_expected(), - } - ); - } - - #[test] - fn configured_npm_check_verifies_the_lock_without_writing() { - let set = npm_set("frontend", "apps/web"); - for frozen in [false, true] { - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - false, - frozen, - &no_head(), - &UvClosure::default() - ) - .expect("npm check"), - CheckPlan::Run { - argv: npm_run( - &no_head(), - &[], - "apps/web", - &["install", "--lockfile-only", "--frozen-lockfile"] - ), - env: npm_env_expected(), - }, - "frozen {frozen}" - ); - } - let checked = check_configured( - ws(), - &set, - &Selection::Full, - true, - false, - &no_head(), - &UvClosure::default(), - ) - .expect("npm offline check"); - assert_eq!( - checked, - CheckPlan::Run { - argv: npm_run( - &no_head(), - &["--repository_disable_download"], - "apps/web", - &[ - "install", - "--lockfile-only", - "--frozen-lockfile", - "--offline" - ] - ), - env: npm_env_expected(), - } - ); - let CheckPlan::Run { argv, .. } = checked else { - panic!("npm check runs pnpm"); - }; - assert!(runs_bazel(&argv)); - let error = check_configured( - ws(), - &set, - &Selection::Packages(vec!["react".to_owned()]), - false, - false, - &no_head(), - &UvClosure::default(), - ) - .expect_err("selective check"); - assert!( - matches!(error, BackendError::Unsupported { ref set, .. } if set == "frontend"), - "{error:?}" - ); - } - - #[test] - fn configured_npm_offline_and_frozen_updates_refuse_before_running() { - let set = npm_set("frontend", "apps/web"); - for request in [ - Selection::Full, - Selection::Packages(vec!["react".to_owned()]), - ] { - let error = plan_configured( - ws(), - &set, - &request, - true, - false, - &no_head(), - &UvClosure::default(), - ) - .expect_err("offline update"); - assert_eq!( - error, - BackendError::OfflineRequired { - set: "frontend".to_owned() - } - ); - let error = plan_configured( - ws(), - &set, - &request, - false, - true, - &no_head(), - &UvClosure::default(), - ) - .expect_err("frozen update"); - assert_eq!( - error, - BackendError::FrozenLocked { - set: "frontend".to_owned() - } - ); - } - } - - #[test] - fn configured_npm_root_set_runs_in_the_workspace_root() { - let set = npm_set("root", "."); - match plan_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &no_head(), - &UvClosure::default(), - ) { - Ok(BackendPlan::Run { argv, .. }) => { - let dir = argv - .iter() - .skip_while(|arg| *arg != "--dir") - .nth(1) - .cloned() - .unwrap_or_default(); - assert_eq!(dir, ws_arg()); - } - other => panic!("{other:?}"), - } - } - - fn cargo_set(name: &str, dir: &str, repository: &str) -> ResolvedSet { - let at = |file: &str| match dir { - "." => file.to_owned(), - _ => format!("{dir}/{file}"), - }; - ResolvedSet { - name: name.to_owned(), - ecosystem: Ecosystem::Cargo, - dir: dir.to_owned(), - manifests: vec![at("Cargo.toml")], - locks: vec![at("Cargo.lock"), at("cargo-bazel-lock.json")], - repository: Some(repository.to_owned()), - } - } - - fn cargo_env_expected(repin: &str, repository: &str) -> Vec<(String, String)> { - let mut env = vec![ - ("CARGO_BAZEL_ISOLATED".to_owned(), "true".to_owned()), - ("CARGO_BAZEL_REPIN".to_owned(), repin.to_owned()), - ("CARGO_BAZEL_REPIN_ONLY".to_owned(), repository.to_owned()), - ("REPIN".to_owned(), repin.to_owned()), - ]; - if repin == "false" { - env.push(("CARGO_BAZEL_DIGEST_CHECK".to_owned(), "1".to_owned())); - } - env - } - - fn cargo_fetch(head: &dx_process::BazelHead, args: &[&str]) -> Vec { - [head.argv("fetch"), strings(args)].concat() - } - - #[test] - fn configured_cargo_update_repins_only_its_crate_universe_repository() { - let head = dx_process::BazelHead { - startup: vec!["--output_base=/ob".to_owned()], - options: vec!["--config=dx_build_dev".to_owned()], - settings: vec![], - }; - for set in [ - cargo_set("crates", "apps/rs", "crates"), - cargo_set("root", ".", "crates"), - ] { - let planned = plan_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &head, - &UvClosure::default(), - ) - .expect("cargo update"); - assert_eq!( - planned, - BackendPlan::Run { - argv: cargo_fetch(&head, &["--repo=@crates"]), - env: cargo_env_expected("workspace", "crates"), - }, - "{}", - set.name - ); - let BackendPlan::Run { argv, .. } = planned else { - panic!("cargo update runs bazel"); - }; - assert!(runs_bazel(&argv)); - let verb = argv.iter().position(|arg| arg == "fetch").expect("verb"); - assert!( - argv.iter().position(|arg| arg == "--output_base=/ob") < Some(verb), - "{argv:?}" - ); - assert_eq!(argv.last().map(String::as_str), Some("--repo=@crates")); - } - } - - #[test] - fn configured_cargo_check_forces_repin_off() { - let set = cargo_set("tools", "tools/rs", "tool_crates"); - for frozen in [false, true] { - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - false, - frozen, - &no_head(), - &UvClosure::default() - ) - .expect("cargo check"), - CheckPlan::Run { - argv: cargo_fetch(&no_head(), &["--repo=@tool_crates"]), - env: cargo_env_expected("false", "tool_crates"), - }, - "frozen {frozen}" - ); - } - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - true, - false, - &no_head(), - &UvClosure::default() - ) - .expect("cargo offline check"), - CheckPlan::Run { - argv: cargo_fetch( - &no_head(), - &["--repository_disable_download", "--repo=@tool_crates"] - ), - env: cargo_env_expected("false", "tool_crates"), - } - ); - } - - #[test] - fn configured_cargo_refuses_offline_frozen_and_package_updates() { - let set = cargo_set("crates", "apps/rs", "crates"); - for (offline, frozen, want) in [ - ( - true, - false, - BackendError::OfflineRequired { - set: "crates".to_owned(), - }, - ), - ( - false, - true, - BackendError::FrozenLocked { - set: "crates".to_owned(), - }, - ), - ] { - assert_eq!( - plan_configured( - ws(), - &set, - &Selection::Full, - offline, - frozen, - &no_head(), - &UvClosure::default() - ), - Err(want) - ); - } - let packages = Selection::Packages(vec!["serde".to_owned()]); - for (offline, frozen, want) in [ - ( - true, - false, - BackendError::OfflineRequired { - set: "crates".to_owned(), - }, - ), - ( - false, - true, - BackendError::FrozenLocked { - set: "crates".to_owned(), - }, - ), - ] { - assert_eq!( - plan_configured( - ws(), - &set, - &packages, - offline, - frozen, - &no_head(), - &UvClosure::default() - ), - Err(want) - ); - } - assert_eq!( - check_configured( - ws(), - &set, - &packages, - false, - false, - &no_head(), - &UvClosure::default() - ), - Err(BackendError::Unsupported { - set: "crates".to_owned(), - reason: CARGO_CHECK_SELECTIVE, - }) - ); - } - - fn nuget_set() -> ResolvedSet { - ResolvedSet { - name: "dotnet".to_owned(), - ecosystem: Ecosystem::Nuget, - dir: "third_party/dotnet".to_owned(), - manifests: vec!["third_party/dotnet/paket.dependencies".to_owned()], - locks: vec![ - "third_party/dotnet/paket.lock".to_owned(), - "third_party/dotnet/deps/paket.main.bzl".to_owned(), - ], - repository: None, - } - } - - #[test] - fn configured_nuget_stages_paket2bazel_without_rules_dx_settings() { - let head = dx_process::BazelHead { - startup: vec!["--output_base=/ob".to_owned()], - options: vec!["--config=dx_build_dev".to_owned()], - settings: vec!["--@rules_dx//config:x=true".to_owned()], - }; - let set = nuget_set(); - let expected = StagedRun { - tool: StagedTool::Paket2Bazel, - argv_head: [ - dx_process::BazelHead { - settings: Vec::new(), - ..head.clone() - } - .argv("run"), - strings(&["@rules_dx//dotnet:paket2bazel", "--"]), - ] - .concat(), - env: Vec::new(), - inputs: vec![ - "third_party/dotnet/paket.dependencies".to_owned(), - "third_party/dotnet/paket.lock".to_owned(), - ], - outputs: vec!["third_party/dotnet/deps/paket.main.bzl".to_owned()], - }; - let closure = UvClosure::default(); - // Frozen is allowed: paket.lock never changes, so the run is a pinned fetch. - for frozen in [false, true] { - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, false, frozen, &head, &closure), - Ok(BackendPlan::Staged(expected.clone())) - ); - assert_eq!( - check_configured(ws(), &set, &Selection::Full, false, frozen, &head, &closure), - Ok(CheckPlan::Staged(expected.clone())) - ); - } - assert!(expected.argv_head.contains(&"--output_base=/ob".to_owned())); - assert!( - !expected - .argv_head - .iter() - .any(|arg| arg.starts_with("--") && arg.contains("rules_dx")) - ); - let argv = expected.argv(Path::new("/scratch/1")); - assert_eq!( - argv[expected.argv_head.len()..], - strings(&[ - "--dependencies-file", - "/scratch/1/paket.dependencies", - "--output-folder", - "/scratch/1/out" - ]) - ); - } - - #[test] - fn configured_nuget_requires_network_and_refuses_packages() { - let set = nuget_set(); - let head = no_head(); - let closure = UvClosure::default(); - for frozen in [false, true] { - let offline = Err(BackendError::OfflineRequired { - set: "dotnet".to_owned(), - }); - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, true, frozen, &head, &closure), - offline - ); - assert_eq!( - check_configured(ws(), &set, &Selection::Full, true, frozen, &head, &closure), - Err(BackendError::OfflineRequired { - set: "dotnet".to_owned() - }) - ); - } - let packages = Selection::Packages(vec!["Newtonsoft.Json".to_owned()]); - let unsupported = Err(BackendError::Unsupported { - set: "dotnet".to_owned(), - reason: NUGET_PINS_EXACT, - }); - assert_eq!( - plan_configured(ws(), &set, &packages, false, false, &head, &closure), - unsupported - ); - assert_eq!( - check_configured(ws(), &set, &packages, false, false, &head, &closure), - Err(BackendError::Unsupported { - set: "dotnet".to_owned(), - reason: NUGET_PINS_EXACT, - }) - ); - } - - fn go_set() -> ResolvedSet { - ResolvedSet { - name: "gomods".to_owned(), - ecosystem: Ecosystem::Go, - dir: "third_party/go".to_owned(), - manifests: vec!["third_party/go/go.mod".to_owned()], - locks: vec!["third_party/go/go.sum".to_owned()], - repository: None, - } - } - - #[test] - fn configured_go_stages_go_mod_download_with_forced_env() { - let head = dx_process::BazelHead { - startup: vec!["--output_base=/ob".to_owned()], - options: vec!["--config=dx_build_dev".to_owned()], - settings: vec!["--@rules_dx//config:x=true".to_owned()], - }; - let set = go_set(); - let expected = StagedRun { - tool: StagedTool::GoModDownload, - argv_head: [ - dx_process::BazelHead { - settings: Vec::new(), - ..head.clone() - } - .argv("run"), - strings(&["@rules_go//go", "--"]), - ] - .concat(), - env: Vec::new(), - inputs: vec![ - "third_party/go/go.mod".to_owned(), - "third_party/go/go.sum".to_owned(), - ], - outputs: vec!["third_party/go/go.sum".to_owned()], - }; - let closure = UvClosure::default(); - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, false, false, &head, &closure), - Ok(BackendPlan::Staged(expected.clone())) - ); - // Frozen check passes through; frozen apply would write go.sum. - for frozen in [false, true] { - assert_eq!( - check_configured(ws(), &set, &Selection::Full, false, frozen, &head, &closure), - Ok(CheckPlan::Staged(expected.clone())) - ); - } - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, false, true, &head, &closure), - Err(BackendError::FrozenLocked { - set: "gomods".to_owned() - }) - ); - assert!( - !expected - .argv_head - .iter() - .any(|arg| arg.contains("rules_dx")) - ); - let scratch = Path::new("/scratch/1"); - let argv = expected.argv(scratch); - assert_eq!( - argv[expected.argv_head.len()..], - strings(&["-C", "/scratch/1/out", "mod", "download", "all"]) - ); - assert_eq!( - expected.env(scratch), - vec![ - ("GOENV".to_owned(), "off".to_owned()), - ("GOWORK".to_owned(), "off".to_owned()), - ("GOTOOLCHAIN".to_owned(), "local".to_owned()), - ("GOFLAGS".to_owned(), "-modcacherw".to_owned()), - ("GOMODCACHE".to_owned(), "/scratch/1/gomodcache".to_owned()), - ("GOPATH".to_owned(), "/scratch/1/gopath".to_owned()), - ("GOCACHE".to_owned(), "/scratch/1/gocache".to_owned()), - ] - ); - assert_eq!(expected.inputs_dir(scratch), Path::new("/scratch/1/out")); - assert_eq!(expected.tool_name(), "go"); - } - - fn hex_set() -> ResolvedSet { - ResolvedSet { - name: "deps".to_owned(), - ecosystem: Ecosystem::Hex, - dir: "third_party/elixir".to_owned(), - manifests: vec!["third_party/elixir/mix.exs".to_owned()], - locks: vec!["third_party/elixir/mix.lock".to_owned()], - repository: None, - } - } - - fn rebar_set() -> ResolvedSet { - ResolvedSet { - name: "erl".to_owned(), - ecosystem: Ecosystem::Hex, - dir: "third_party/erlang".to_owned(), - manifests: vec!["third_party/erlang/rebar.config".to_owned()], - locks: vec!["third_party/erlang/rebar.lock".to_owned()], - repository: None, - } - } - - #[test] - fn rebar_check_is_pinned_and_update_is_unsupported() { - let head = no_head(); - let closure = UvClosure::default(); - let set = rebar_set(); - assert_eq!( - check_configured(ws(), &set, &Selection::Full, false, true, &head, &closure), - Ok(CheckPlan::Pinned) - ); - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, false, false, &head, &closure), - Err(BackendError::Unsupported { - set: "erl".to_owned(), - reason: "rebar.lock refresh: #9884", - }) - ); - } - - #[test] - fn hex_check_stages_deps_get_and_update_names_the_packages() { - let head = no_head(); - let closure = UvClosure::default(); - let set = hex_set(); - let Ok(CheckPlan::Staged(check)) = - check_configured(ws(), &set, &Selection::Full, false, true, &head, &closure) - else { - panic!("staged check"); - }; - let scratch = Path::new("/scratch/1"); - let argv = check.argv(scratch); - let under = "--run_under=cd '/scratch/1/out' &&".to_owned(); - let target = argv - .iter() - .position(|arg| *arg == under) - .expect("run_under"); - assert_eq!( - argv[target + 1..], - strings(&["@rules_dx//elixir/tools:mix", "--", "deps.get"]) - ); - assert_eq!(check.outputs, ["third_party/elixir/mix.lock"]); - assert_eq!( - check.inputs, - ["third_party/elixir/mix.exs", "third_party/elixir/mix.lock"] - ); - let env = check.env(scratch); - assert!(env.contains(&("MIX_HOME".to_owned(), "/scratch/1/mix_home".to_owned()))); - assert!(env.contains(&("HEX_HOME".to_owned(), "/scratch/1/hex_home".to_owned()))); - assert_eq!(check.tool_name(), "mix"); - - let packages = Selection::Packages(vec!["jason".to_owned(), "plug".to_owned()]); - let Ok(BackendPlan::Staged(update)) = - plan_configured(ws(), &set, &packages, false, false, &head, &closure) - else { - panic!("staged update"); - }; - let argv = update.argv(scratch); - assert_eq!( - argv[argv.len() - 4..], - strings(&["--", "deps.update", "jason", "plug"]) - ); - let Ok(BackendPlan::Staged(all)) = - plan_configured(ws(), &set, &Selection::Full, false, false, &head, &closure) - else { - panic!("staged full update"); - }; - assert_eq!( - all.argv_head[all.argv_head.len() - 2..], - strings(&["deps.update", "--all"]) - ); - } - - #[test] - fn hex_refuses_offline_frozen_and_checks_with_packages() { - let set = hex_set(); - let head = no_head(); - let closure = UvClosure::default(); - let offline = BackendError::OfflineRequired { - set: "deps".to_owned(), - }; - assert_eq!( - check_configured(ws(), &set, &Selection::Full, true, false, &head, &closure), - Err(offline.clone()) - ); - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, true, false, &head, &closure), - Err(offline) - ); - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, false, true, &head, &closure), - Err(BackendError::FrozenLocked { - set: "deps".to_owned() - }) - ); - let packages = Selection::Packages(vec!["jason".to_owned()]); - assert!(matches!( - check_configured(ws(), &set, &packages, false, false, &head, &closure), - Err(BackendError::Unsupported { .. }) - )); - } - - #[test] - fn configured_go_requires_network_and_refuses_packages() { - let set = go_set(); - let head = no_head(); - let closure = UvClosure::default(); - for frozen in [false, true] { - assert_eq!( - check_configured(ws(), &set, &Selection::Full, true, frozen, &head, &closure), - Err(BackendError::OfflineRequired { - set: "gomods".to_owned() - }) - ); - } - assert_eq!( - plan_configured(ws(), &set, &Selection::Full, true, false, &head, &closure), - Err(BackendError::OfflineRequired { - set: "gomods".to_owned() - }) - ); - let packages = Selection::Packages(vec!["github.com/google/go-cmp".to_owned()]); - let want = BackendError::Unsupported { - set: "gomods".to_owned(), - reason: GO_WHOLE_GRAPH, - }; - assert_eq!( - plan_configured(ws(), &set, &packages, false, false, &head, &closure), - Err(want.clone()) - ); - assert_eq!( - check_configured(ws(), &set, &packages, false, false, &head, &closure), - Err(want) - ); - } - - #[test] - fn go_host_env_that_redirects_or_weakens_fetching_is_refused() { - let set = go_set(); - for key in GO_HOST_ENV { - let refusal = host_env_refusal(&set, true, &|name| name == key); - assert_eq!( - refusal, - Some(BackendError::Unsupported { - set: "gomods".to_owned(), - reason: GO_HOST_ENV_REASON, - }), - "{key}" - ); - } - assert_eq!(host_env_refusal(&set, false, &|_| false), None); - assert_eq!( - host_env_refusal(&set, false, &|name| name == "GOFLAGS"), - None - ); - } - - #[test] - fn uv_host_env_that_changes_resolution_is_refused() { - let set = ResolvedSet { - name: "pypi".to_owned(), - ecosystem: Ecosystem::Uv, - dir: ".".to_owned(), - manifests: vec!["pyproject.toml".to_owned()], - locks: vec!["uv.lock".to_owned()], - repository: None, - }; - for key in UV_HOST_ENV { - let refusal = host_env_refusal(&set, false, &|name| name == key); - assert_eq!( - refusal, - Some(BackendError::Unsupported { - set: "pypi".to_owned(), - reason: UV_HOST_ENV_REASON, - }), - "{key}" - ); - } - assert_eq!(host_env_refusal(&set, true, &|_| false), None); - assert_eq!( - host_env_refusal(&set, false, &|name| name == "UV_CACHE_DIR"), - None - ); - } - - fn maven_set(name: &str, repository: &str) -> ResolvedSet { - ResolvedSet { - name: name.to_owned(), - ecosystem: Ecosystem::Maven, - dir: ".".to_owned(), - manifests: vec!["MODULE.bazel".to_owned()], - locks: vec![format!("{repository}_install.json")], - repository: Some(repository.to_owned()), - } - } - - #[test] - fn configured_maven_update_runs_only_its_pin_target() { - let head = dx_process::BazelHead { - startup: vec!["--output_base=/ob".to_owned()], - options: vec!["--config=dx_build_dev".to_owned()], - settings: vec!["--@rules_dx//config:x=true".to_owned()], - }; - let set = maven_set("tools", "tool_maven"); - let plan = plan_configured( - ws(), - &set, - &Selection::Full, - false, - false, - &head, - &UvClosure::default(), - ) - .expect("maven update"); - assert_eq!( - plan, - BackendPlan::Run { - argv: [ - dx_process::BazelHead { - settings: Vec::new(), - ..head.clone() - } - .argv("run"), - strings(&["@tool_maven//:pin"]) - ] - .concat(), - env: vec![("RULES_JVM_EXTERNAL_REPIN".to_owned(), "1".to_owned())], - } - ); - let BackendPlan::Run { argv, env } = plan else { - unreachable!() - }; - assert!(argv.contains(&"--output_base=/ob".to_owned()), "{argv:?}"); - assert!(env.iter().all(|(key, _)| key != "REPIN")); - } - - #[test] - fn configured_maven_check_fetches_without_repin_env() { - let set = maven_set("tools", "tool_maven"); - for (offline, args) in [ - (false, vec!["--repo=@tool_maven"]), - ( - true, - vec!["--repository_disable_download", "--repo=@tool_maven"], - ), - ] { - assert_eq!( - check_configured( - ws(), - &set, - &Selection::Full, - offline, - false, - &no_head(), - &UvClosure::default() - ) - .expect("maven check"), - CheckPlan::Run { - argv: cargo_fetch(&no_head(), &args), - env: Vec::new(), - }, - "offline {offline}" - ); - } - } - - #[test] - fn configured_maven_refuses_offline_frozen_and_packages() { - let set = maven_set("jvm", "maven"); - for (offline, frozen, want) in [ - ( - true, - false, - BackendError::OfflineRequired { - set: "jvm".to_owned(), - }, - ), - ( - false, - true, - BackendError::FrozenLocked { - set: "jvm".to_owned(), - }, - ), - ] { - assert_eq!( - plan_configured( - ws(), - &set, - &Selection::Full, - offline, - frozen, - &no_head(), - &UvClosure::default() - ), - Err(want) - ); - } - let packages = Selection::Packages(vec!["com.google.guava:guava".to_owned()]); - let unsupported = Err(BackendError::Unsupported { - set: "jvm".to_owned(), - reason: MAVEN_ONE_PACKAGE, - }); - assert_eq!( - plan_configured( - ws(), - &set, - &packages, - false, - false, - &no_head(), - &UvClosure::default() - ), - unsupported - ); - assert_eq!( - check_configured( - ws(), - &set, - &packages, - false, - false, - &no_head(), - &UvClosure::default() - ), - unsupported.map(|_: BackendPlan| CheckPlan::Pinned) - ); - } - - #[test] - fn maven_host_env_refusal_truth_table() { - let maven = maven_set("jvm", "maven"); - let cargo = cargo_set("crates", "apps/rs", "crates"); - let with = |key: &'static str| move |name: &str| name == key; - for key in ["REPIN", "RULES_JVM_EXTERNAL_REPIN"] { - assert!( - host_env_refusal(&maven, true, &with(key)).is_some(), - "{key}" - ); - assert_eq!(host_env_refusal(&maven, false, &with(key)), None, "{key}"); - assert_eq!(host_env_refusal(&cargo, true, &with(key)), None, "{key}"); - } - for key in MAVEN_COURSIER_ENV { - for check in [false, true] { - assert!( - host_env_refusal(&maven, check, &with(key)).is_some(), - "{key}" - ); - } - assert_eq!(host_env_refusal(&cargo, true, &with(key)), None, "{key}"); - } - assert_eq!(host_env_refusal(&maven, true, &|_| false), None); - } - - #[test] - fn configured_cargo_package_update_repins_one_crate() { - let set = cargo_set("crates", "apps/rs", "crates"); - assert_eq!( - plan_configured( - ws(), - &set, - &Selection::Packages(vec!["serde".to_owned()]), - false, - false, - &no_head(), - &UvClosure::default() - ) - .expect("cargo package plan"), - BackendPlan::Run { - argv: cargo_fetch(&no_head(), &["--repo=@crates"]), - env: cargo_env_expected("serde", "crates"), - } - ); - } -} +#[path = "backend_tests.rs"] +mod tests; diff --git a/cli/update/src/backend_tests.rs b/cli/update/src/backend_tests.rs new file mode 100644 index 0000000000..462b1e830a --- /dev/null +++ b/cli/update/src/backend_tests.rs @@ -0,0 +1,2456 @@ +use super::*; + +fn ws() -> &'static Path { + Path::new("/dx-workspace") +} + +fn ws_arg() -> String { + ws().to_string_lossy().into_owned() +} + +fn dir_arg(rel: &str) -> String { + ws().join(rel).to_string_lossy().into_owned() +} + +fn no_head() -> dx_process::BazelHead { + dx_process::BazelHead::default() +} + +#[test] +fn rewrite_generated_points_nuget_repo_at_rules_dx() { + let mut run = StagedRun { + tool: StagedTool::Paket2Bazel, + argv_head: Vec::new(), + env: Vec::new(), + inputs: Vec::new(), + outputs: Vec::new(), + }; + let input = b"a\nload(\"@rules_dotnet//dotnet:defs.bzl\", \"nuget_repo\")\nb\n".to_vec(); + let want = b"a\nload(\"@rules_dx//dotnet:defs.bzl\", \"nuget_repo\")\nb\n".to_vec(); + assert_eq!(run.rewrite_generated(input.clone()), want); + run.tool = StagedTool::GoModDownload; + assert_eq!(run.rewrite_generated(input.clone()), input); +} + +#[test] +fn backend_error_message_starts_with_code() { + let set = "go".to_owned(); + for error in [ + BackendError::Unsupported { + set: set.clone(), + reason: "why", + }, + BackendError::OfflineRequired { set: set.clone() }, + BackendError::FrozenLocked { set: set.clone() }, + BackendError::BackendUnavailable { + set, + tool: "uv", + var: UV_BIN_VAR, + what: "uv", + detail: None, + }, + ] { + assert!(error.to_string().starts_with(error.code()), "{error}"); + } +} + +#[test] +fn unavailable_renders_the_bazel_detail_and_keeps_the_pin_text() { + let error = BackendError::BackendUnavailable { + set: "s".to_owned(), + tool: "uv", + var: UV_BIN_VAR, + what: "uv", + detail: Some("exit 1: boom".to_owned()), + }; + let text = error.to_string(); + assert!(text.contains("set DX_UV_BIN"), "{text}"); + assert!(text.contains("PATH is never searched"), "{text}"); + assert!( + text.ends_with("; Bazel resolution failed: exit 1: boom"), + "{text}" + ); +} + +fn configured_set(dir: &str) -> ResolvedSet { + ResolvedSet { + name: "frontend".to_owned(), + ecosystem: Ecosystem::Uv, + dir: dir.to_owned(), + manifests: vec![format!("{dir}/pyproject.toml")], + locks: vec![format!("{dir}/uv.lock")], + repository: None, + } +} + +fn closure() -> UvClosure { + UvClosure { + uv: Some(abs("uv")), + python: Some(abs("python3")), + ..UvClosure::default() + } +} + +fn abs(name: &str) -> String { + if cfg!(windows) { + format!("C:\\pinned\\{name}") + } else { + format!("/pinned/{name}") + } +} + +fn uv_argv(check: bool, offline: bool) -> Vec { + let mut argv = vec![abs("uv"), "lock".to_owned()]; + if check { + argv.push("--check".to_owned()); + } + argv.extend(strings(&[ + "--directory", + "apps/frontend", + "--no-config", + "--no-cache", + "--no-python-downloads", + "--python", + ])); + argv.push(abs("python3")); + if offline { + argv.push("--offline".to_owned()); + } + argv +} + +#[test] +fn configured_uv_plans_run_the_pinned_closure_in_the_set_directory() { + let set = configured_set("apps/frontend"); + assert_eq!( + plan_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &no_head(), + &closure() + ) + .expect("configured update"), + BackendPlan::Run { + argv: uv_argv(false, false), + env: vec![], + } + ); + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &no_head(), + &closure() + ) + .expect("configured check"), + CheckPlan::Run { + argv: uv_argv(true, false), + env: vec![], + } + ); + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + true, + false, + &no_head(), + &closure() + ) + .expect("configured check offline"), + CheckPlan::Run { + argv: uv_argv(true, true), + env: vec![], + } + ); +} + +#[test] +fn configured_uv_without_a_pinned_closure_is_unavailable() { + let set = configured_set("apps/frontend"); + let cases = [ + (UvClosure::default(), UV_BIN_VAR), + ( + UvClosure { + uv: Some("uv".to_owned()), + python: Some(abs("python3")), + ..UvClosure::default() + }, + UV_BIN_VAR, + ), + ( + UvClosure { + uv: Some(abs("uv")), + python: None, + ..UvClosure::default() + }, + UV_PYTHON_VAR, + ), + ( + UvClosure { + uv: Some(abs("uv")), + python: Some("python3".to_owned()), + ..UvClosure::default() + }, + UV_PYTHON_VAR, + ), + ]; + for (closure, var) in cases { + let error = plan_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &no_head(), + &closure, + ) + .expect_err("update needs the pinned closure"); + assert!( + matches!(error, BackendError::BackendUnavailable { var: got, .. } if got == var), + "{error:?}" + ); + let message = error.to_string(); + assert!(message.starts_with("backend_unavailable:"), "{message}"); + assert!(message.contains("cannot run uv for frontend"), "{message}"); + assert!(message.contains("frontend"), "{message}"); + assert!(message.contains(var), "{message}"); + assert!(message.contains("PATH is never searched"), "{message}"); + let error = check_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &no_head(), + &closure, + ) + .expect_err("check needs the pinned closure"); + assert!( + matches!(error, BackendError::BackendUnavailable { var: got, .. } if got == var), + "{error:?}" + ); + } +} + +#[test] +fn configured_bazel_lock_plans_a_nested_mod_deps() { + let set = bazel_set("tools", "tools/MODULE.bazel.lock"); + let head = dx_process::BazelHead { + startup: vec!["--output_base=/x".to_owned()], + options: vec!["--config=ci".to_owned()], + settings: Vec::new(), + }; + let plan = plan_configured_bazel_lock(&set, false, false, &head).expect("plan"); + assert_eq!(plan.dir, "tools"); + assert_eq!( + plan.argv[plan.argv.len() - 3..], + ["mod", "deps", "--lockfile_mode=update"] + ); + assert!(plan.argv.iter().any(|arg| arg == "--max_idle_secs=15")); + assert!(!plan.argv.iter().any(|arg| arg.contains("--output_base"))); + assert!(!plan.argv.iter().any(|arg| arg.contains("--config"))); +} + +#[test] +fn configured_bazel_lock_at_the_root_uses_the_head() { + let set = bazel_set(".", "MODULE.bazel.lock"); + let plan = plan_configured_bazel_lock(&set, false, false, &no_head()).expect("plan"); + assert_eq!(plan.dir, "."); + assert_eq!( + plan.argv[plan.argv.len() - 3..], + ["mod", "deps", "--lockfile_mode=update"] + ); +} + +#[test] +fn configured_bazel_lock_refuses_frozen_offline_and_foreign_locks() { + let set = bazel_set("tools", "tools/MODULE.bazel.lock"); + assert!(matches!( + plan_configured_bazel_lock(&set, false, true, &no_head()), + Err(BackendError::FrozenLocked { .. }) + )); + assert!(matches!( + plan_configured_bazel_lock(&set, true, false, &no_head()), + Err(BackendError::OfflineRequired { .. }) + )); + let foreign = bazel_set("tools", "other/MODULE.bazel.lock"); + assert!(matches!( + plan_configured_bazel_lock(&foreign, false, false, &no_head()), + Err(BackendError::Unsupported { .. }) + )); +} + +fn ruby_set() -> ResolvedSet { + ResolvedSet { + name: "bundle".to_owned(), + ecosystem: Ecosystem::Ruby, + dir: "app".to_owned(), + manifests: vec!["app/Gemfile".to_owned()], + locks: vec!["app/Gemfile.lock".to_owned()], + repository: None, + } +} + +#[test] +fn ruby_lock_runs_bundler_from_the_toolchain_in_the_gemfile_dir() { + let plan = plan_ruby_lock(&ruby_set(), "rake", false, false, &no_head()).expect("plan"); + assert_eq!(plan.dir, "app"); + assert!(plan.env.is_empty()); + let tail = plan.argv.iter().position(|arg| arg == "@ruby//:bundle"); + let tail = &plan.argv[tail.expect("bundle target")..]; + assert_eq!( + tail, + [ + "@ruby//:bundle", + "--", + "lock", + "--update", + "rake", + "--conservative" + ] + ); +} + +#[test] +fn ruby_lock_refuses_frozen_and_offline() { + assert!(matches!( + plan_ruby_lock(&ruby_set(), "rake", false, true, &no_head()), + Err(BackendError::FrozenLocked { .. }) + )); + assert!(matches!( + plan_ruby_lock(&ruby_set(), "rake", true, false, &no_head()), + Err(BackendError::OfflineRequired { .. }) + )); +} + +fn composer_set(locks: &[&str]) -> ResolvedSet { + ResolvedSet { + name: "php".to_owned(), + ecosystem: Ecosystem::Composer, + dir: "php".to_owned(), + manifests: vec!["php/composer.json".to_owned()], + locks: locks.iter().map(|lock| (*lock).to_owned()).collect(), + repository: None, + } +} + +fn composer_update( + set: &ResolvedSet, + offline: bool, + frozen: bool, +) -> Result { + plan_configured( + ws(), + set, + &Selection::Packages(vec!["psr/log".to_owned()]), + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) +} + +const COMPOSER_LOCKS: [&str; 2] = ["php/composer.lock", "php/composer.digests.json"]; + +#[test] +fn composer_update_runs_composer_then_the_digest_refresh() { + let set = composer_set(&COMPOSER_LOCKS); + let BackendPlan::Sequence(runs) = composer_update(&set, false, false).expect("plan") else { + panic!("expected a sequence"); + }; + assert_eq!(runs.len(), 2); + assert_eq!( + runs[0].argv[runs[0].argv.len() - 6..], + [ + "@rules_dx//php/tools:composer", + "--", + "update", + "psr/log", + "--with-all-dependencies", + "--no-install", + ] + ); + assert_eq!(runs[0].env[0].0, "COMPOSER_HOME"); + assert_eq!(runs[0].env[1].0, "COMPOSER_CACHE_DIR"); + assert_eq!( + runs[1].argv[runs[1].argv.len() - 4..], + [ + "@rules_dx//php/tools/digests:refresh_digests", + "--", + "composer.lock", + "composer.digests.json" + ] + ); +} + +#[test] +fn composer_update_without_digests_runs_only_composer() { + let set = composer_set(&["php/composer.lock"]); + let BackendPlan::Sequence(runs) = composer_update(&set, false, false).expect("plan") else { + panic!("expected a sequence"); + }; + assert_eq!(runs.len(), 1); +} + +#[test] +fn composer_update_refuses_frozen_and_offline() { + let set = composer_set(&COMPOSER_LOCKS); + assert!(matches!( + composer_update(&set, true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + composer_update(&set, false, true), + Err(BackendError::FrozenLocked { .. }) + )); +} + +#[test] +fn composer_check_validates_the_lock() { + let set = composer_set(&COMPOSER_LOCKS); + let plan = check_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("plan"); + let CheckPlan::Run { argv, env } = plan else { + panic!("expected a run"); + }; + assert_eq!( + argv[argv.len() - 5..], + [ + "@rules_dx//php/tools:composer", + "--", + "validate", + "--check-lock", + "--no-check-publish" + ] + ); + assert_eq!(env[0].0, "COMPOSER_HOME"); +} + +#[test] +fn composer_check_refuses_a_package_selection() { + let set = composer_set(&COMPOSER_LOCKS); + let error = check_configured( + ws(), + &set, + &Selection::Packages(vec!["psr/log".to_owned()]), + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect_err("refused"); + assert!(error.to_string().contains("whole composer.lock")); +} + +fn cpan_set() -> ResolvedSet { + ResolvedSet { + name: "perl".to_owned(), + ecosystem: Ecosystem::Cpan, + dir: "app".to_owned(), + manifests: vec!["app/cpanfile".to_owned()], + locks: vec![ + "app/cpanfile.snapshot".to_owned(), + "app/cpanfile.snapshot.lock.json".to_owned(), + ], + repository: None, + } +} + +fn cpan_workspace(build: &str) -> dx_test_scratch::TempDir { + let dir = dx_test_scratch::scratch("cpan_backend"); + dx_test_scratch::write(dir.path(), "app/BUILD.bazel", build); + dir +} + +const COMPILER_BUILD: &str = "cpan_compiler(name = \"compiler\", cpanfile = \"cpanfile\", lockfile = \"cpanfile.snapshot.lock.json\")\n"; + +fn cpan_update(workspace: &Path, offline: bool, frozen: bool) -> Result { + plan_configured( + workspace, + &cpan_set(), + &Selection::Packages(vec!["Try::Tiny".to_owned()]), + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) +} + +#[test] +fn cpan_update_runs_carton_then_the_compiler() { + let dir = cpan_workspace(COMPILER_BUILD); + let BackendPlan::Sequence(runs) = cpan_update(dir.path(), false, false).expect("plan") else { + panic!("expected a sequence"); + }; + assert_eq!(runs.len(), 2); + let carton = &runs[0].argv; + let tail = carton + .iter() + .position(|arg| arg.starts_with("--run_under=")); + assert_eq!( + carton[tail.expect("run_under") + 1..], + ["@rules_dx//perl/tools:carton", "--", "update", "Try::Tiny"] + ); + assert_eq!(runs[0].env[0].0, "PERL_CARTON_PATH"); + assert_eq!( + runs[1].argv[runs[1].argv.len() - 3..], + ["//app:compiler", "--", "--incremental"] + ); +} + +#[test] +fn cpan_update_refuses_without_a_compiler() { + let dir = cpan_workspace(""); + let error = cpan_update(dir.path(), false, false).expect_err("refused"); + assert!( + error + .to_string() + .contains("add a cpan_compiler next to the cpanfile") + ); +} + +#[test] +fn cpan_update_refuses_frozen_and_offline() { + let dir = cpan_workspace(COMPILER_BUILD); + assert!(matches!( + cpan_update(dir.path(), true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + cpan_update(dir.path(), false, true), + Err(BackendError::FrozenLocked { .. }) + )); +} + +#[test] +fn cpan_check_installs_the_snapshot_in_deployment_mode() { + let plan = check_configured( + ws(), + &cpan_set(), + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("plan"); + let CheckPlan::Run { argv, .. } = plan else { + panic!("expected a run"); + }; + assert_eq!(argv[argv.len() - 2..], ["install", "--deployment"]); + assert!(matches!( + check_configured( + ws(), + &cpan_set(), + &Selection::Full, + true, + false, + &no_head(), + &UvClosure::default() + ), + Err(BackendError::OfflineRequired { .. }) + )); +} + +fn julia_set() -> ResolvedSet { + ResolvedSet { + name: "julia".to_owned(), + ecosystem: Ecosystem::Julia, + dir: "app".to_owned(), + manifests: vec!["app/Project.toml".to_owned()], + locks: vec![ + "app/Manifest.toml".to_owned(), + "app/Manifest.bazel.json".to_owned(), + ], + repository: None, + } +} + +const JULIA_BUILD: &str = "julia_pkg_resolve(name = \"resolve\")\njulia_pkg_resolve_test(name = \"resolve_test\", resolver = \":resolve\")\n"; + +fn julia_workspace(build: &str) -> dx_test_scratch::TempDir { + let dir = dx_test_scratch::scratch("julia_backend"); + dx_test_scratch::write(dir.path(), "app/BUILD.bazel", build); + dir +} + +fn julia_update( + workspace: &Path, + request: Selection, + offline: bool, + frozen: bool, +) -> Result { + plan_configured( + workspace, + &julia_set(), + &request, + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) +} + +#[test] +fn julia_update_runs_pkg_update_then_the_resolver() { + let dir = julia_workspace(JULIA_BUILD); + let request = Selection::Packages(vec!["JSON".to_owned()]); + let BackendPlan::Sequence(runs) = + julia_update(dir.path(), request, false, false).expect("plan") + else { + panic!("expected a sequence"); + }; + assert_eq!(runs.len(), 2); + let app = dir.path().join("app").to_string_lossy().into_owned(); + assert_eq!( + runs[0].argv[runs[0].argv.len() - 4..], + [ + "@rules_dx//julia/tools:pkg_update", + "--", + app.as_str(), + "JSON" + ] + ); + assert_eq!(runs[0].env[0].0, "JULIA_DEPOT_PATH"); + assert_eq!( + runs[1].argv.last().map(String::as_str), + Some("//app:resolve") + ); +} + +#[test] +fn julia_full_update_names_no_package() { + let dir = julia_workspace(JULIA_BUILD); + let BackendPlan::Sequence(runs) = + julia_update(dir.path(), Selection::Full, false, false).expect("plan") + else { + panic!("expected a sequence"); + }; + assert_eq!( + runs[0].argv.last().map(String::as_str), + dir.path().join("app").to_str() + ); +} + +#[test] +fn julia_update_refuses_without_a_resolver_frozen_and_offline() { + let bare = julia_workspace(""); + let error = julia_update(bare.path(), Selection::Full, false, false).expect_err("refused"); + assert!(error.to_string().contains("add a julia_pkg_resolve")); + let dir = julia_workspace(JULIA_BUILD); + assert!(matches!( + julia_update(dir.path(), Selection::Full, true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + julia_update(dir.path(), Selection::Full, false, true), + Err(BackendError::FrozenLocked { .. }) + )); +} + +#[test] +fn julia_check_runs_the_resolve_test() { + let check = |workspace: &Path, request: &Selection, offline: bool| { + check_configured( + workspace, + &julia_set(), + request, + offline, + false, + &no_head(), + &UvClosure::default(), + ) + }; + let dir = julia_workspace(JULIA_BUILD); + let CheckPlan::Run { argv, .. } = check(dir.path(), &Selection::Full, false).expect("plan") + else { + panic!("expected a run"); + }; + assert_eq!(argv.last().map(String::as_str), Some("//app:resolve_test")); + let bare = julia_workspace(""); + let error = check(bare.path(), &Selection::Full, false).expect_err("refused"); + assert!(error.to_string().contains("add a julia_pkg_resolve_test")); + assert!(matches!( + check(dir.path(), &Selection::Full, true), + Err(BackendError::OfflineRequired { .. }) + )); +} + +fn ansible_set() -> ResolvedSet { + ResolvedSet { + name: "cols".to_owned(), + ecosystem: Ecosystem::Ansible, + dir: ".".to_owned(), + manifests: vec!["MODULE.bazel".to_owned()], + locks: vec!["play/lock.json".to_owned()], + repository: None, + } +} + +#[test] +fn ansible_update_runs_the_lock_target_per_collection() { + let dir = dx_test_scratch::scratch("ansible_backend"); + dx_test_scratch::write( + dir.path(), + "play/BUILD.bazel", + "ansible_lock(name = \"lock\")\n", + ); + dx_test_scratch::write( + dir.path(), + "play/lock.json", + "{\"collections\": [{\"name\": \"a.one\"}, {\"name\": \"b.two\"}]}", + ); + let plan = |request: &Selection, offline: bool| { + plan_configured( + dir.path(), + &ansible_set(), + request, + offline, + false, + &no_head(), + &UvClosure::default(), + ) + }; + let BackendPlan::Sequence(runs) = plan(&Selection::Full, false).expect("plan") else { + panic!("expected a sequence"); + }; + let tails: Vec<_> = runs + .iter() + .map(|run| run.argv[run.argv.len() - 3..].join(" ")) + .collect(); + assert_eq!(tails, ["//play:lock -- a.one", "//play:lock -- b.two"]); + let BackendPlan::Sequence(one) = + plan(&Selection::Packages(vec!["c.three".to_owned()]), false).expect("plan") + else { + panic!("expected a sequence"); + }; + assert_eq!(one.len(), 1); + assert!(matches!( + plan(&Selection::Full, true), + Err(BackendError::OfflineRequired { .. }) + )); + let bare = dx_test_scratch::scratch("ansible_backend_bare"); + let error = plan_configured( + bare.path(), + &ansible_set(), + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect_err("refused"); + assert!(error.to_string().contains("add an ansible_lock")); +} + +fn cabal_set() -> ResolvedSet { + ResolvedSet { + name: "hackage".to_owned(), + ecosystem: Ecosystem::Cabal, + dir: "hs".to_owned(), + manifests: vec!["hs/cabal.project".to_owned()], + locks: vec![ + "hs/cabal.project.freeze".to_owned(), + "hs/haskell_deps.json".to_owned(), + ], + repository: None, + } +} + +const CABAL_BUILD: &str = "haskell_deps_resolve(name = \"resolve\")\nhaskell_deps_resolve_test(name = \"resolve_test\")\n"; + +#[test] +fn cabal_update_runs_the_resolve_target() { + let dir = dx_test_scratch::scratch("cabal_backend"); + dx_test_scratch::write(dir.path(), "hs/BUILD.bazel", CABAL_BUILD); + let plan = |request: &Selection, offline: bool, frozen: bool| { + plan_configured( + dir.path(), + &cabal_set(), + request, + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) + }; + let BackendPlan::Run { argv, .. } = plan(&Selection::Full, false, false).expect("plan") else { + panic!("expected a run"); + }; + assert_eq!(argv.last().map(String::as_str), Some("//hs:resolve")); + assert!(matches!( + plan(&Selection::Full, true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + plan(&Selection::Full, false, true), + Err(BackendError::FrozenLocked { .. }) + )); + assert!(plan(&Selection::Packages(vec!["safe".to_owned()]), false, false).is_err()); +} + +#[test] +fn cabal_check_runs_the_resolve_test() { + let dir = dx_test_scratch::scratch("cabal_backend_check"); + dx_test_scratch::write(dir.path(), "hs/BUILD.bazel", CABAL_BUILD); + let CheckPlan::Run { argv, .. } = check_configured( + dir.path(), + &cabal_set(), + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("plan") else { + panic!("expected a run"); + }; + assert_eq!(argv.last().map(String::as_str), Some("//hs:resolve_test")); + let bare = dx_test_scratch::scratch("cabal_backend_bare"); + let error = check_configured( + bare.path(), + &cabal_set(), + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect_err("refused"); + assert!( + error + .to_string() + .contains("add a haskell_deps_resolve_test") + ); +} + +fn swift_set() -> ResolvedSet { + ResolvedSet { + name: "swift".to_owned(), + ecosystem: Ecosystem::SwiftPm, + dir: "app".to_owned(), + manifests: vec!["app/Package.swift".to_owned()], + locks: vec!["app/Package.resolved".to_owned()], + repository: None, + } +} + +fn opam_set() -> ResolvedSet { + ResolvedSet { + name: "ocaml".to_owned(), + ecosystem: Ecosystem::Opam, + dir: "app".to_owned(), + manifests: vec!["app/app.opam".to_owned()], + locks: vec!["app/app.opam.locked".to_owned()], + repository: None, + } +} + +fn swift_update( + packages: &[&str], + offline: bool, + frozen: bool, +) -> Result { + plan_configured( + ws(), + &swift_set(), + &Selection::Packages(strings(packages)), + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) +} + +fn opam_update(offline: bool, frozen: bool) -> Result { + plan_configured( + ws(), + &opam_set(), + &Selection::Packages(vec!["astring".to_owned()]), + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) +} + +#[test] +fn swift_check_resolves_in_a_copy_and_compares() { + let plan = check_configured( + ws(), + &swift_set(), + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("plan"); + let CheckPlan::Run { argv, .. } = plan else { + panic!("expected a run"); + }; + assert!(argv.iter().any(|arg| arg.starts_with("--run_under=sh -c") + && arg.ends_with(&format!("sh '{}' check", dir_arg("app"))))); + assert_eq!( + argv[argv.len() - 4..], + ["@rules_dx//swift/tools:swift", "--", "package", "resolve"] + ); + for (request, offline) in [ + (Selection::Full, true), + (Selection::Packages(strings(&["a"])), false), + ] { + assert!( + check_configured( + ws(), + &swift_set(), + &request, + offline, + false, + &no_head(), + &UvClosure::default() + ) + .is_err() + ); + } +} + +#[test] +fn swift_update_names_identities_and_refuses_frozen_and_offline() { + let Ok(BackendPlan::Run { argv, .. }) = swift_update(&["github.com/vapor/Vapor"], false, false) + else { + panic!("expected a run"); + }; + assert!( + argv.iter() + .any(|arg| arg.ends_with(&format!("sh '{}' apply", dir_arg("app")))) + ); + assert_eq!(argv[argv.len() - 3..], ["package", "update", "vapor"]); + assert!(matches!( + swift_update(&["a"], true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + swift_update(&["a"], false, true), + Err(BackendError::FrozenLocked { .. }) + )); +} + +#[test] +fn opam_update_upgrades_then_relocks() { + let BackendPlan::Sequence(runs) = opam_update(false, false).expect("plan") else { + panic!("expected a sequence"); + }; + assert_eq!(runs.len(), 2); + assert_eq!( + runs[0].argv[runs[0].argv.len() - 2..], + ["upgrade", "astring"] + ); + assert_eq!(runs[1].argv[runs[1].argv.len() - 2..], ["lock", "app.opam"]); + assert!( + runs[0] + .argv + .iter() + .any(|a| a.contains("cd '/dx-workspace/app'")) + ); + assert!( + runs[1] + .argv + .contains(&"@rules_dx//ocaml/tools:opam".to_owned()) + ); + assert_eq!(runs[0].env[0].0, "OPAMROOT"); +} + +#[test] +fn opam_update_refuses_frozen_and_offline() { + assert!(matches!( + opam_update(false, true), + Err(BackendError::FrozenLocked { .. }) + )); + assert!(matches!( + opam_update(true, false), + Err(BackendError::OfflineRequired { .. }) + )); +} + +#[test] +fn opam_check_dry_runs_the_locked_install() { + let plan = check_configured( + ws(), + &opam_set(), + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("plan"); + let CheckPlan::Run { argv, .. } = plan else { + panic!("expected a run"); + }; + assert_eq!( + argv[argv.len() - 6..], + [ + "install", + "--locked", + "--deps-only", + "--dry-run", + "--show-actions", + "." + ] + ); + assert!(matches!( + check_configured( + ws(), + &opam_set(), + &Selection::Full, + true, + false, + &no_head(), + &UvClosure::default() + ), + Err(BackendError::OfflineRequired { .. }) + )); +} + +fn pub_set() -> ResolvedSet { + ResolvedSet { + name: "dart_deps".to_owned(), + ecosystem: Ecosystem::Pub, + dir: "app".to_owned(), + manifests: vec!["app/pubspec.yaml".to_owned()], + locks: vec!["app/pubspec.lock".to_owned()], + repository: None, + } +} + +fn pub_workspace(pubspec: &str) -> dx_test_scratch::TempDir { + let dir = dx_test_scratch::scratch("pub_backend"); + dx_test_scratch::write(dir.path(), "app/pubspec.yaml", pubspec); + dir +} + +fn pub_plan( + workspace: &Path, + request: &Selection, + offline: bool, + frozen: bool, +) -> Result { + plan_configured( + workspace, + &pub_set(), + request, + offline, + frozen, + &no_head(), + &UvClosure::default(), + ) +} + +#[test] +fn pub_update_runs_dart_pub_upgrade() { + let dir = pub_workspace("name: app\n"); + let BackendPlan::Run { argv, env } = pub_plan( + dir.path(), + &Selection::Packages(vec!["http".to_owned()]), + false, + false, + ) + .expect("plan") else { + panic!("expected a run"); + }; + assert_eq!(argv[argv.len() - 4..], ["--", "pub", "upgrade", "http"]); + assert!(argv.contains(&"@rules_dx//dart/tools:dart".to_owned())); + assert_eq!(env[0].0, "PUB_CACHE"); + let BackendPlan::Run { argv, .. } = + pub_plan(dir.path(), &Selection::Full, false, false).expect("plan") + else { + panic!("expected a run"); + }; + assert_eq!(argv[argv.len() - 2..], ["pub", "upgrade"]); +} + +#[test] +fn pub_update_refuses_frozen_and_offline() { + let dir = pub_workspace("name: app\n"); + assert!(matches!( + pub_plan(dir.path(), &Selection::Full, true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + pub_plan(dir.path(), &Selection::Full, false, true), + Err(BackendError::FrozenLocked { .. }) + )); + let flutter = pub_workspace(FLUTTER_PUBSPEC); + assert!(matches!( + pub_plan(flutter.path(), &Selection::Full, true, false), + Err(BackendError::OfflineRequired { .. }) + )); + assert!(matches!( + pub_plan(flutter.path(), &Selection::Full, false, true), + Err(BackendError::FrozenLocked { .. }) + )); +} + +const FLUTTER_PUBSPEC: &str = "dependencies:\n flutter:\n sdk: flutter\n"; + +#[test] +fn flutter_pub_update_runs_the_rules_flutter_pub_target() { + let dir = pub_workspace(FLUTTER_PUBSPEC); + let BackendPlan::Run { argv, env } = pub_plan( + dir.path(), + &Selection::Packages(vec!["http".to_owned()]), + false, + false, + ) + .expect("plan") else { + panic!("expected a run"); + }; + let position = argv + .iter() + .position(|arg| arg == "@rules_flutter//flutter:pub") + .expect("flutter pub target"); + let app = dir.path().join("app").to_string_lossy().into_owned(); + assert_eq!( + argv[position + 1..], + ["--", "pub", "upgrade", "http", "--directory", app.as_str()] + ); + assert_eq!(env[0].0, "PUB_CACHE"); +} + +#[test] +fn flutter_pub_check_enforces_the_lockfile() { + let dir = pub_workspace(FLUTTER_PUBSPEC); + let check = |offline| { + check_configured( + dir.path(), + &pub_set(), + &Selection::Full, + offline, + false, + &no_head(), + &UvClosure::default(), + ) + }; + let Ok(CheckPlan::Run { argv, .. }) = check(true) else { + panic!("expected a run"); + }; + let position = argv + .iter() + .position(|arg| arg == "@rules_flutter//flutter:pub") + .expect("flutter pub target"); + let app = dir.path().join("app").to_string_lossy().into_owned(); + assert_eq!( + argv[position + 1..], + [ + "--", + "pub", + "get", + "--enforce-lockfile", + "--offline", + "--directory", + app.as_str() + ] + ); +} + +#[test] +fn pub_check_enforces_the_lockfile() { + let dir = pub_workspace("name: app\n"); + let check = |offline| { + check_configured( + dir.path(), + &pub_set(), + &Selection::Full, + offline, + false, + &no_head(), + &UvClosure::default(), + ) + }; + let Ok(CheckPlan::Run { argv, .. }) = check(false) else { + panic!("expected a run"); + }; + assert_eq!(argv[argv.len() - 3..], ["pub", "get", "--enforce-lockfile"]); + let Ok(CheckPlan::Run { argv, .. }) = check(true) else { + panic!("expected a run"); + }; + assert_eq!(argv[argv.len() - 1], "--offline"); +} + +fn bazel_set(dir: &str, lock: &str) -> ResolvedSet { + ResolvedSet { + name: "bazel-modules".to_owned(), + ecosystem: Ecosystem::Bazel, + dir: dir.to_owned(), + manifests: vec![format!("{dir}/MODULE.bazel")], + locks: vec![lock.to_owned()], + repository: None, + } +} + +#[test] +fn configured_bazel_selective_points_at_update() { + let set = ResolvedSet { + name: "bazel-modules".to_owned(), + ecosystem: Ecosystem::Bazel, + dir: "tools".to_owned(), + manifests: vec!["tools/MODULE.bazel".to_owned()], + locks: vec!["tools/MODULE.bazel.lock".to_owned()], + repository: None, + }; + let request = Selection::Packages(vec!["rules_cc".to_owned()]); + let planned = plan_configured(ws(), &set, &request, false, false, &no_head(), &closure()) + .expect_err("bazel pins are not resolved"); + let checked = check_configured(ws(), &set, &request, false, false, &no_head(), &closure()) + .expect_err("bazel pins are not checked per module"); + for error in [planned, checked] { + assert!( + matches!( + error, + BackendError::Unsupported { ref set, reason } + if set == "bazel-modules" + && reason.contains("dx update : ") + && reason.contains("bazel mod deps") + ), + "{error:?}" + ); + } +} + +fn pinned_sets() -> Vec<(ResolvedSet, &'static str, &'static str)> { + vec![ + ( + ResolvedSet { + name: "gems".to_owned(), + ecosystem: Ecosystem::Ruby, + dir: "apps/rb".to_owned(), + manifests: vec!["apps/rb/Gemfile".to_owned()], + locks: vec!["apps/rb/Gemfile.lock".to_owned()], + repository: None, + }, + "rspec-core", + "`bundle lock`", + ), + ( + ResolvedSet { + name: "modules".to_owned(), + ecosystem: Ecosystem::PowerShell, + dir: "tools/ps".to_owned(), + manifests: vec!["tools/ps/PSGallery.requirements.psd1".to_owned()], + locks: vec!["tools/ps/PSGallery.lock.json".to_owned()], + repository: None, + }, + "Pester", + "PSGallery.lock.json by hand", + ), + ( + ResolvedSet { + name: "rocks".to_owned(), + ecosystem: Ecosystem::LuaRocks, + dir: "apps/lua".to_owned(), + manifests: vec!["apps/lua/luarocks.lock".to_owned()], + locks: vec![ + "apps/lua/luarocks.lock".to_owned(), + "apps/lua/luarocks.digests.json".to_owned(), + ], + repository: None, + }, + "inspect", + "edit luarocks.lock and luarocks.digests.json by hand", + ), + ] +} + +#[test] +fn configured_pinned_sets_never_run_and_check_as_pinned_under_every_flag() { + for (set, _, _) in pinned_sets() { + for offline in [false, true] { + for frozen in [false, true] { + assert_eq!( + plan_configured( + ws(), + &set, + &Selection::Full, + offline, + frozen, + &no_head(), + &closure() + ), + Ok(BackendPlan::Noop), + "{} offline={offline} frozen={frozen}", + set.name + ); + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + offline, + frozen, + &no_head(), + &UvClosure::default() + ), + Ok(CheckPlan::Pinned), + "{} offline={offline} frozen={frozen}", + set.name + ); + } + } + } +} + +#[test] +fn configured_pinned_selective_names_the_lock_to_regenerate() { + for (set, package, regenerate) in pinned_sets() { + let request = Selection::Packages(vec![package.to_owned()]); + let planned = plan_configured(ws(), &set, &request, false, false, &no_head(), &closure()) + .expect_err("pinned sets resolve nothing"); + let checked = check_configured(ws(), &set, &request, false, false, &no_head(), &closure()) + .expect_err("pinned sets check nothing per package"); + let manifest = set.manifests[0].rsplit('/').next().expect("file"); + let lock = set.locks[0].rsplit('/').next().expect("file"); + for error in [planned, checked] { + assert!( + matches!( + error, + BackendError::Unsupported { set: ref name, reason } + if *name == set.name + && reason.contains(manifest) + && reason.contains(lock) + && reason.contains(regenerate) + ), + "{error:?}" + ); + assert_eq!(error.code(), "unsupported"); + } + } +} + +#[test] +fn configured_uv_selective_and_offline_update_fail_closed() { + let set = configured_set("apps/frontend"); + let error = plan_configured( + ws(), + &set, + &Selection::Packages(vec!["anyio".to_owned()]), + false, + false, + &no_head(), + &closure(), + ) + .expect_err("configured selective stays unsupported"); + assert!( + matches!(error, BackendError::Unsupported { .. }), + "{error:?}" + ); + assert!(error.to_string().contains("frontend")); + let error = check_configured( + ws(), + &set, + &Selection::Packages(vec!["anyio".to_owned()]), + false, + false, + &no_head(), + &closure(), + ) + .expect_err("configured selective check stays unsupported"); + assert!( + matches!(error, BackendError::Unsupported { .. }), + "{error:?}" + ); + let error = plan_configured( + ws(), + &set, + &Selection::Full, + true, + false, + &no_head(), + &closure(), + ) + .expect_err("offline update needs network"); + assert!( + matches!(error, BackendError::OfflineRequired { .. }), + "{error:?}" + ); + assert!(error.to_string().contains("offline_required")); +} + +#[test] +fn frozen_configured_update_is_locked_while_check_passes_through() { + let set = configured_set("apps/frontend"); + let error = plan_configured( + ws(), + &set, + &Selection::Full, + false, + true, + &no_head(), + &closure(), + ) + .expect_err("configured update frozen"); + assert!( + matches!(error, BackendError::FrozenLocked { .. }), + "{error:?}" + ); + assert!(error.to_string().contains("frozen_locked"), "{error}"); + assert!(error.to_string().contains("frontend"), "{error}"); + let checked = check_configured( + ws(), + &set, + &Selection::Full, + false, + true, + &no_head(), + &closure(), + ) + .expect("configured check frozen"); + assert!( + matches!(checked, CheckPlan::Run { .. }), + "uv lock --check verifies without resolving: {checked:?}" + ); +} + +fn npm_set(name: &str, dir: &str) -> ResolvedSet { + ResolvedSet { + name: name.to_owned(), + ecosystem: Ecosystem::Npm, + dir: dir.to_owned(), + manifests: vec![format!("{dir}/package.json")], + locks: vec![format!("{dir}/pnpm-lock.yaml")], + repository: None, + } +} + +fn npm_env_expected() -> Vec<(String, String)> { + let null = if cfg!(windows) { "NUL" } else { "/dev/null" }; + vec![ + ("npm_config_globalconfig".to_owned(), null.to_owned()), + ("npm_config_userconfig".to_owned(), null.to_owned()), + ] +} + +fn npm_run(head: &dx_process::BazelHead, run: &[&str], dir: &str, command: &[&str]) -> Vec { + [ + head.argv("run"), + strings(run), + strings(&["@pnpm//:pnpm", "--", "--dir"]), + vec![dir_arg(dir)], + strings(command), + ] + .concat() +} + +#[cfg(unix)] +#[test] +fn non_utf8_set_directory_is_refused_before_any_tool_runs() { + use std::os::unix::ffi::OsStrExt; + let workspace = Path::new(std::ffi::OsStr::from_bytes(b"/ws\xff")); + let set = npm_set("frontend", "apps/web"); + let err = plan_configured( + workspace, + &set, + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect_err("non-UTF-8 dir"); + assert_eq!(err.code(), "invalid_path"); + assert!(err.to_string().contains("/ws\u{fffd}/apps/web"), "{err}"); +} + +#[test] +fn configured_npm_update_runs_the_consumer_pnpm_in_the_set_directory() { + let set = npm_set("frontend", "apps/web"); + let head = dx_process::BazelHead { + startup: vec!["--output_base=/ob".to_owned()], + options: vec![], + settings: vec![], + }; + let planned = plan_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &head, + &UvClosure::default(), + ) + .expect("npm update"); + assert_eq!( + planned, + BackendPlan::Run { + argv: npm_run(&head, &[], "apps/web", &["install", "--lockfile-only"]), + env: npm_env_expected(), + } + ); + let BackendPlan::Run { argv, .. } = planned else { + panic!("npm update runs pnpm"); + }; + assert!(runs_bazel(&argv)); + assert!(argv.contains(&"--output_base=/ob".to_owned()), "{argv:?}"); + let planned = plan_configured( + ws(), + &set, + &Selection::Packages(vec!["@types/node".to_owned(), "react".to_owned()]), + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("npm selective update"); + assert_eq!( + planned, + BackendPlan::Run { + argv: npm_run( + &no_head(), + &[], + "apps/web", + &["update", "@types/node", "react", "--lockfile-only"] + ), + env: npm_env_expected(), + } + ); +} + +#[test] +fn configured_npm_check_verifies_the_lock_without_writing() { + let set = npm_set("frontend", "apps/web"); + for frozen in [false, true] { + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + false, + frozen, + &no_head(), + &UvClosure::default() + ) + .expect("npm check"), + CheckPlan::Run { + argv: npm_run( + &no_head(), + &[], + "apps/web", + &["install", "--lockfile-only", "--frozen-lockfile"] + ), + env: npm_env_expected(), + }, + "frozen {frozen}" + ); + } + let checked = check_configured( + ws(), + &set, + &Selection::Full, + true, + false, + &no_head(), + &UvClosure::default(), + ) + .expect("npm offline check"); + assert_eq!( + checked, + CheckPlan::Run { + argv: npm_run( + &no_head(), + &["--repository_disable_download"], + "apps/web", + &[ + "install", + "--lockfile-only", + "--frozen-lockfile", + "--offline" + ] + ), + env: npm_env_expected(), + } + ); + let CheckPlan::Run { argv, .. } = checked else { + panic!("npm check runs pnpm"); + }; + assert!(runs_bazel(&argv)); + let error = check_configured( + ws(), + &set, + &Selection::Packages(vec!["react".to_owned()]), + false, + false, + &no_head(), + &UvClosure::default(), + ) + .expect_err("selective check"); + assert!( + matches!(error, BackendError::Unsupported { ref set, .. } if set == "frontend"), + "{error:?}" + ); +} + +#[test] +fn configured_npm_offline_and_frozen_updates_refuse_before_running() { + let set = npm_set("frontend", "apps/web"); + for request in [ + Selection::Full, + Selection::Packages(vec!["react".to_owned()]), + ] { + let error = plan_configured( + ws(), + &set, + &request, + true, + false, + &no_head(), + &UvClosure::default(), + ) + .expect_err("offline update"); + assert_eq!( + error, + BackendError::OfflineRequired { + set: "frontend".to_owned() + } + ); + let error = plan_configured( + ws(), + &set, + &request, + false, + true, + &no_head(), + &UvClosure::default(), + ) + .expect_err("frozen update"); + assert_eq!( + error, + BackendError::FrozenLocked { + set: "frontend".to_owned() + } + ); + } +} + +#[test] +fn configured_npm_root_set_runs_in_the_workspace_root() { + let set = npm_set("root", "."); + match plan_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &no_head(), + &UvClosure::default(), + ) { + Ok(BackendPlan::Run { argv, .. }) => { + let dir = argv + .iter() + .skip_while(|arg| *arg != "--dir") + .nth(1) + .cloned() + .unwrap_or_default(); + assert_eq!(dir, ws_arg()); + } + other => panic!("{other:?}"), + } +} + +fn cargo_set(name: &str, dir: &str, repository: &str) -> ResolvedSet { + let at = |file: &str| match dir { + "." => file.to_owned(), + _ => format!("{dir}/{file}"), + }; + ResolvedSet { + name: name.to_owned(), + ecosystem: Ecosystem::Cargo, + dir: dir.to_owned(), + manifests: vec![at("Cargo.toml")], + locks: vec![at("Cargo.lock"), at("cargo-bazel-lock.json")], + repository: Some(repository.to_owned()), + } +} + +fn cargo_env_expected(repin: &str, repository: &str) -> Vec<(String, String)> { + let mut env = vec![ + ("CARGO_BAZEL_ISOLATED".to_owned(), "true".to_owned()), + ("CARGO_BAZEL_REPIN".to_owned(), repin.to_owned()), + ("CARGO_BAZEL_REPIN_ONLY".to_owned(), repository.to_owned()), + ("REPIN".to_owned(), repin.to_owned()), + ]; + if repin == "false" { + env.push(("CARGO_BAZEL_DIGEST_CHECK".to_owned(), "1".to_owned())); + } + env +} + +fn cargo_fetch(head: &dx_process::BazelHead, args: &[&str]) -> Vec { + [head.argv("fetch"), strings(args)].concat() +} + +#[test] +fn configured_cargo_update_repins_only_its_crate_universe_repository() { + let head = dx_process::BazelHead { + startup: vec!["--output_base=/ob".to_owned()], + options: vec!["--config=dx_build_dev".to_owned()], + settings: vec![], + }; + for set in [ + cargo_set("crates", "apps/rs", "crates"), + cargo_set("root", ".", "crates"), + ] { + let planned = plan_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &head, + &UvClosure::default(), + ) + .expect("cargo update"); + assert_eq!( + planned, + BackendPlan::Run { + argv: cargo_fetch(&head, &["--repo=@crates"]), + env: cargo_env_expected("workspace", "crates"), + }, + "{}", + set.name + ); + let BackendPlan::Run { argv, .. } = planned else { + panic!("cargo update runs bazel"); + }; + assert!(runs_bazel(&argv)); + let verb = argv.iter().position(|arg| arg == "fetch").expect("verb"); + assert!( + argv.iter().position(|arg| arg == "--output_base=/ob") < Some(verb), + "{argv:?}" + ); + assert_eq!(argv.last().map(String::as_str), Some("--repo=@crates")); + } +} + +#[test] +fn configured_cargo_check_forces_repin_off() { + let set = cargo_set("tools", "tools/rs", "tool_crates"); + for frozen in [false, true] { + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + false, + frozen, + &no_head(), + &UvClosure::default() + ) + .expect("cargo check"), + CheckPlan::Run { + argv: cargo_fetch(&no_head(), &["--repo=@tool_crates"]), + env: cargo_env_expected("false", "tool_crates"), + }, + "frozen {frozen}" + ); + } + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + true, + false, + &no_head(), + &UvClosure::default() + ) + .expect("cargo offline check"), + CheckPlan::Run { + argv: cargo_fetch( + &no_head(), + &["--repository_disable_download", "--repo=@tool_crates"] + ), + env: cargo_env_expected("false", "tool_crates"), + } + ); +} + +#[test] +fn configured_cargo_refuses_offline_frozen_and_package_updates() { + let set = cargo_set("crates", "apps/rs", "crates"); + for (offline, frozen, want) in [ + ( + true, + false, + BackendError::OfflineRequired { + set: "crates".to_owned(), + }, + ), + ( + false, + true, + BackendError::FrozenLocked { + set: "crates".to_owned(), + }, + ), + ] { + assert_eq!( + plan_configured( + ws(), + &set, + &Selection::Full, + offline, + frozen, + &no_head(), + &UvClosure::default() + ), + Err(want) + ); + } + let packages = Selection::Packages(vec!["serde".to_owned()]); + for (offline, frozen, want) in [ + ( + true, + false, + BackendError::OfflineRequired { + set: "crates".to_owned(), + }, + ), + ( + false, + true, + BackendError::FrozenLocked { + set: "crates".to_owned(), + }, + ), + ] { + assert_eq!( + plan_configured( + ws(), + &set, + &packages, + offline, + frozen, + &no_head(), + &UvClosure::default() + ), + Err(want) + ); + } + assert_eq!( + check_configured( + ws(), + &set, + &packages, + false, + false, + &no_head(), + &UvClosure::default() + ), + Err(BackendError::Unsupported { + set: "crates".to_owned(), + reason: CARGO_CHECK_SELECTIVE, + }) + ); +} + +fn nuget_set() -> ResolvedSet { + ResolvedSet { + name: "dotnet".to_owned(), + ecosystem: Ecosystem::Nuget, + dir: "third_party/dotnet".to_owned(), + manifests: vec!["third_party/dotnet/paket.dependencies".to_owned()], + locks: vec![ + "third_party/dotnet/paket.lock".to_owned(), + "third_party/dotnet/deps/paket.main.bzl".to_owned(), + ], + repository: None, + } +} + +#[test] +fn configured_nuget_stages_paket2bazel_without_rules_dx_settings() { + let head = dx_process::BazelHead { + startup: vec!["--output_base=/ob".to_owned()], + options: vec!["--config=dx_build_dev".to_owned()], + settings: vec!["--@rules_dx//config:x=true".to_owned()], + }; + let set = nuget_set(); + let expected = StagedRun { + tool: StagedTool::Paket2Bazel, + argv_head: [ + dx_process::BazelHead { + settings: Vec::new(), + ..head.clone() + } + .argv("run"), + strings(&["@rules_dx//dotnet:paket2bazel", "--"]), + ] + .concat(), + env: Vec::new(), + inputs: vec![ + "third_party/dotnet/paket.dependencies".to_owned(), + "third_party/dotnet/paket.lock".to_owned(), + ], + outputs: vec!["third_party/dotnet/deps/paket.main.bzl".to_owned()], + }; + let closure = UvClosure::default(); + // Frozen is allowed: paket.lock never changes, so the run is a pinned fetch. + for frozen in [false, true] { + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, false, frozen, &head, &closure), + Ok(BackendPlan::Staged(expected.clone())) + ); + assert_eq!( + check_configured(ws(), &set, &Selection::Full, false, frozen, &head, &closure), + Ok(CheckPlan::Staged(expected.clone())) + ); + } + assert!(expected.argv_head.contains(&"--output_base=/ob".to_owned())); + assert!( + !expected + .argv_head + .iter() + .any(|arg| arg.starts_with("--") && arg.contains("rules_dx")) + ); + let argv = expected.argv(Path::new("/scratch/1")); + assert_eq!( + argv[expected.argv_head.len()..], + strings(&[ + "--dependencies-file", + "/scratch/1/paket.dependencies", + "--output-folder", + "/scratch/1/out" + ]) + ); +} + +#[test] +fn configured_nuget_requires_network_and_refuses_packages() { + let set = nuget_set(); + let head = no_head(); + let closure = UvClosure::default(); + for frozen in [false, true] { + let offline = Err(BackendError::OfflineRequired { + set: "dotnet".to_owned(), + }); + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, true, frozen, &head, &closure), + offline + ); + assert_eq!( + check_configured(ws(), &set, &Selection::Full, true, frozen, &head, &closure), + Err(BackendError::OfflineRequired { + set: "dotnet".to_owned() + }) + ); + } + let packages = Selection::Packages(vec!["Newtonsoft.Json".to_owned()]); + let unsupported = Err(BackendError::Unsupported { + set: "dotnet".to_owned(), + reason: NUGET_PINS_EXACT, + }); + assert_eq!( + plan_configured(ws(), &set, &packages, false, false, &head, &closure), + unsupported + ); + assert_eq!( + check_configured(ws(), &set, &packages, false, false, &head, &closure), + Err(BackendError::Unsupported { + set: "dotnet".to_owned(), + reason: NUGET_PINS_EXACT, + }) + ); +} + +fn go_set() -> ResolvedSet { + ResolvedSet { + name: "gomods".to_owned(), + ecosystem: Ecosystem::Go, + dir: "third_party/go".to_owned(), + manifests: vec!["third_party/go/go.mod".to_owned()], + locks: vec!["third_party/go/go.sum".to_owned()], + repository: None, + } +} + +#[test] +fn configured_go_stages_go_mod_download_with_forced_env() { + let head = dx_process::BazelHead { + startup: vec!["--output_base=/ob".to_owned()], + options: vec!["--config=dx_build_dev".to_owned()], + settings: vec!["--@rules_dx//config:x=true".to_owned()], + }; + let set = go_set(); + let expected = StagedRun { + tool: StagedTool::GoModDownload, + argv_head: [ + dx_process::BazelHead { + settings: Vec::new(), + ..head.clone() + } + .argv("run"), + strings(&["@rules_go//go", "--"]), + ] + .concat(), + env: Vec::new(), + inputs: vec![ + "third_party/go/go.mod".to_owned(), + "third_party/go/go.sum".to_owned(), + ], + outputs: vec!["third_party/go/go.sum".to_owned()], + }; + let closure = UvClosure::default(); + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, false, false, &head, &closure), + Ok(BackendPlan::Staged(expected.clone())) + ); + // Frozen check passes through; frozen apply would write go.sum. + for frozen in [false, true] { + assert_eq!( + check_configured(ws(), &set, &Selection::Full, false, frozen, &head, &closure), + Ok(CheckPlan::Staged(expected.clone())) + ); + } + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, false, true, &head, &closure), + Err(BackendError::FrozenLocked { + set: "gomods".to_owned() + }) + ); + assert!( + !expected + .argv_head + .iter() + .any(|arg| arg.contains("rules_dx")) + ); + let scratch = Path::new("/scratch/1"); + let argv = expected.argv(scratch); + assert_eq!( + argv[expected.argv_head.len()..], + strings(&["-C", "/scratch/1/out", "mod", "download", "all"]) + ); + assert_eq!( + expected.env(scratch), + vec![ + ("GOENV".to_owned(), "off".to_owned()), + ("GOWORK".to_owned(), "off".to_owned()), + ("GOTOOLCHAIN".to_owned(), "local".to_owned()), + ("GOFLAGS".to_owned(), "-modcacherw".to_owned()), + ("GOMODCACHE".to_owned(), "/scratch/1/gomodcache".to_owned()), + ("GOPATH".to_owned(), "/scratch/1/gopath".to_owned()), + ("GOCACHE".to_owned(), "/scratch/1/gocache".to_owned()), + ] + ); + assert_eq!(expected.inputs_dir(scratch), Path::new("/scratch/1/out")); + assert_eq!(expected.tool_name(), "go"); +} + +fn hex_set() -> ResolvedSet { + ResolvedSet { + name: "deps".to_owned(), + ecosystem: Ecosystem::Hex, + dir: "third_party/elixir".to_owned(), + manifests: vec!["third_party/elixir/mix.exs".to_owned()], + locks: vec!["third_party/elixir/mix.lock".to_owned()], + repository: None, + } +} + +fn rebar_set() -> ResolvedSet { + ResolvedSet { + name: "erl".to_owned(), + ecosystem: Ecosystem::Hex, + dir: "third_party/erlang".to_owned(), + manifests: vec!["third_party/erlang/rebar.config".to_owned()], + locks: vec!["third_party/erlang/rebar.lock".to_owned()], + repository: None, + } +} + +#[test] +fn rebar_check_is_pinned_and_update_is_unsupported() { + let head = no_head(); + let closure = UvClosure::default(); + let set = rebar_set(); + assert_eq!( + check_configured(ws(), &set, &Selection::Full, false, true, &head, &closure), + Ok(CheckPlan::Pinned) + ); + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, false, false, &head, &closure), + Err(BackendError::Unsupported { + set: "erl".to_owned(), + reason: "rebar.lock refresh: #9884", + }) + ); +} + +#[test] +fn hex_check_stages_deps_get_and_update_names_the_packages() { + let head = no_head(); + let closure = UvClosure::default(); + let set = hex_set(); + let Ok(CheckPlan::Staged(check)) = + check_configured(ws(), &set, &Selection::Full, false, true, &head, &closure) + else { + panic!("staged check"); + }; + let scratch = Path::new("/scratch/1"); + let argv = check.argv(scratch); + let under = "--run_under=cd '/scratch/1/out' &&".to_owned(); + let target = argv + .iter() + .position(|arg| *arg == under) + .expect("run_under"); + assert_eq!( + argv[target + 1..], + strings(&["@rules_dx//elixir/tools:mix", "--", "deps.get"]) + ); + assert_eq!(check.outputs, ["third_party/elixir/mix.lock"]); + assert_eq!( + check.inputs, + ["third_party/elixir/mix.exs", "third_party/elixir/mix.lock"] + ); + let env = check.env(scratch); + assert!(env.contains(&("MIX_HOME".to_owned(), "/scratch/1/mix_home".to_owned()))); + assert!(env.contains(&("HEX_HOME".to_owned(), "/scratch/1/hex_home".to_owned()))); + assert_eq!(check.tool_name(), "mix"); + + let packages = Selection::Packages(vec!["jason".to_owned(), "plug".to_owned()]); + let Ok(BackendPlan::Staged(update)) = + plan_configured(ws(), &set, &packages, false, false, &head, &closure) + else { + panic!("staged update"); + }; + let argv = update.argv(scratch); + assert_eq!( + argv[argv.len() - 4..], + strings(&["--", "deps.update", "jason", "plug"]) + ); + let Ok(BackendPlan::Staged(all)) = + plan_configured(ws(), &set, &Selection::Full, false, false, &head, &closure) + else { + panic!("staged full update"); + }; + assert_eq!( + all.argv_head[all.argv_head.len() - 2..], + strings(&["deps.update", "--all"]) + ); +} + +#[test] +fn hex_refuses_offline_frozen_and_checks_with_packages() { + let set = hex_set(); + let head = no_head(); + let closure = UvClosure::default(); + let offline = BackendError::OfflineRequired { + set: "deps".to_owned(), + }; + assert_eq!( + check_configured(ws(), &set, &Selection::Full, true, false, &head, &closure), + Err(offline.clone()) + ); + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, true, false, &head, &closure), + Err(offline) + ); + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, false, true, &head, &closure), + Err(BackendError::FrozenLocked { + set: "deps".to_owned() + }) + ); + let packages = Selection::Packages(vec!["jason".to_owned()]); + assert!(matches!( + check_configured(ws(), &set, &packages, false, false, &head, &closure), + Err(BackendError::Unsupported { .. }) + )); +} + +#[test] +fn configured_go_requires_network_and_refuses_packages() { + let set = go_set(); + let head = no_head(); + let closure = UvClosure::default(); + for frozen in [false, true] { + assert_eq!( + check_configured(ws(), &set, &Selection::Full, true, frozen, &head, &closure), + Err(BackendError::OfflineRequired { + set: "gomods".to_owned() + }) + ); + } + assert_eq!( + plan_configured(ws(), &set, &Selection::Full, true, false, &head, &closure), + Err(BackendError::OfflineRequired { + set: "gomods".to_owned() + }) + ); + let packages = Selection::Packages(vec!["github.com/google/go-cmp".to_owned()]); + let want = BackendError::Unsupported { + set: "gomods".to_owned(), + reason: GO_WHOLE_GRAPH, + }; + assert_eq!( + plan_configured(ws(), &set, &packages, false, false, &head, &closure), + Err(want.clone()) + ); + assert_eq!( + check_configured(ws(), &set, &packages, false, false, &head, &closure), + Err(want) + ); +} + +#[test] +fn go_host_env_that_redirects_or_weakens_fetching_is_refused() { + let set = go_set(); + for key in GO_HOST_ENV { + let refusal = host_env_refusal(&set, true, &|name| name == key); + assert_eq!( + refusal, + Some(BackendError::Unsupported { + set: "gomods".to_owned(), + reason: GO_HOST_ENV_REASON, + }), + "{key}" + ); + } + assert_eq!(host_env_refusal(&set, false, &|_| false), None); + assert_eq!( + host_env_refusal(&set, false, &|name| name == "GOFLAGS"), + None + ); +} + +#[test] +fn uv_host_env_that_changes_resolution_is_refused() { + let set = ResolvedSet { + name: "pypi".to_owned(), + ecosystem: Ecosystem::Uv, + dir: ".".to_owned(), + manifests: vec!["pyproject.toml".to_owned()], + locks: vec!["uv.lock".to_owned()], + repository: None, + }; + for key in UV_HOST_ENV { + let refusal = host_env_refusal(&set, false, &|name| name == key); + assert_eq!( + refusal, + Some(BackendError::Unsupported { + set: "pypi".to_owned(), + reason: UV_HOST_ENV_REASON, + }), + "{key}" + ); + } + assert_eq!(host_env_refusal(&set, true, &|_| false), None); + assert_eq!( + host_env_refusal(&set, false, &|name| name == "UV_CACHE_DIR"), + None + ); +} + +fn maven_set(name: &str, repository: &str) -> ResolvedSet { + ResolvedSet { + name: name.to_owned(), + ecosystem: Ecosystem::Maven, + dir: ".".to_owned(), + manifests: vec!["MODULE.bazel".to_owned()], + locks: vec![format!("{repository}_install.json")], + repository: Some(repository.to_owned()), + } +} + +#[test] +fn configured_maven_update_runs_only_its_pin_target() { + let head = dx_process::BazelHead { + startup: vec!["--output_base=/ob".to_owned()], + options: vec!["--config=dx_build_dev".to_owned()], + settings: vec!["--@rules_dx//config:x=true".to_owned()], + }; + let set = maven_set("tools", "tool_maven"); + let plan = plan_configured( + ws(), + &set, + &Selection::Full, + false, + false, + &head, + &UvClosure::default(), + ) + .expect("maven update"); + assert_eq!( + plan, + BackendPlan::Run { + argv: [ + dx_process::BazelHead { + settings: Vec::new(), + ..head.clone() + } + .argv("run"), + strings(&["@tool_maven//:pin"]) + ] + .concat(), + env: vec![("RULES_JVM_EXTERNAL_REPIN".to_owned(), "1".to_owned())], + } + ); + let BackendPlan::Run { argv, env } = plan else { + unreachable!() + }; + assert!(argv.contains(&"--output_base=/ob".to_owned()), "{argv:?}"); + assert!(env.iter().all(|(key, _)| key != "REPIN")); +} + +#[test] +fn configured_maven_check_fetches_without_repin_env() { + let set = maven_set("tools", "tool_maven"); + for (offline, args) in [ + (false, vec!["--repo=@tool_maven"]), + ( + true, + vec!["--repository_disable_download", "--repo=@tool_maven"], + ), + ] { + assert_eq!( + check_configured( + ws(), + &set, + &Selection::Full, + offline, + false, + &no_head(), + &UvClosure::default() + ) + .expect("maven check"), + CheckPlan::Run { + argv: cargo_fetch(&no_head(), &args), + env: Vec::new(), + }, + "offline {offline}" + ); + } +} + +#[test] +fn configured_maven_refuses_offline_frozen_and_packages() { + let set = maven_set("jvm", "maven"); + for (offline, frozen, want) in [ + ( + true, + false, + BackendError::OfflineRequired { + set: "jvm".to_owned(), + }, + ), + ( + false, + true, + BackendError::FrozenLocked { + set: "jvm".to_owned(), + }, + ), + ] { + assert_eq!( + plan_configured( + ws(), + &set, + &Selection::Full, + offline, + frozen, + &no_head(), + &UvClosure::default() + ), + Err(want) + ); + } + let packages = Selection::Packages(vec!["com.google.guava:guava".to_owned()]); + let unsupported = Err(BackendError::Unsupported { + set: "jvm".to_owned(), + reason: MAVEN_ONE_PACKAGE, + }); + assert_eq!( + plan_configured( + ws(), + &set, + &packages, + false, + false, + &no_head(), + &UvClosure::default() + ), + unsupported + ); + assert_eq!( + check_configured( + ws(), + &set, + &packages, + false, + false, + &no_head(), + &UvClosure::default() + ), + unsupported.map(|_: BackendPlan| CheckPlan::Pinned) + ); +} + +#[test] +fn maven_host_env_refusal_truth_table() { + let maven = maven_set("jvm", "maven"); + let cargo = cargo_set("crates", "apps/rs", "crates"); + let with = |key: &'static str| move |name: &str| name == key; + for key in ["REPIN", "RULES_JVM_EXTERNAL_REPIN"] { + assert!( + host_env_refusal(&maven, true, &with(key)).is_some(), + "{key}" + ); + assert_eq!(host_env_refusal(&maven, false, &with(key)), None, "{key}"); + assert_eq!(host_env_refusal(&cargo, true, &with(key)), None, "{key}"); + } + for key in MAVEN_COURSIER_ENV { + for check in [false, true] { + assert!( + host_env_refusal(&maven, check, &with(key)).is_some(), + "{key}" + ); + } + assert_eq!(host_env_refusal(&cargo, true, &with(key)), None, "{key}"); + } + assert_eq!(host_env_refusal(&maven, true, &|_| false), None); +} + +#[test] +fn configured_cargo_package_update_repins_one_crate() { + let set = cargo_set("crates", "apps/rs", "crates"); + assert_eq!( + plan_configured( + ws(), + &set, + &Selection::Packages(vec!["serde".to_owned()]), + false, + false, + &no_head(), + &UvClosure::default() + ) + .expect("cargo package plan"), + BackendPlan::Run { + argv: cargo_fetch(&no_head(), &["--repo=@crates"]), + env: cargo_env_expected("serde", "crates"), + } + ); +} diff --git a/deploy/release/BUILD.bazel b/deploy/release/BUILD.bazel index 90580cc4e2..79289bba22 100644 --- a/deploy/release/BUILD.bazel +++ b/deploy/release/BUILD.bazel @@ -112,6 +112,7 @@ rust_test( "src/modules.rs", "src/provenance.rs", "src/publish.rs", + "src/release_lib_tests.rs", "src/runtimes.rs", "src/spdx.rs", "src/tool.rs", diff --git a/deploy/release/src/lib.rs b/deploy/release/src/lib.rs index 141eb945eb..b314441d41 100644 --- a/deploy/release/src/lib.rs +++ b/deploy/release/src/lib.rs @@ -1375,1677 +1375,5 @@ pub fn notice_verify_files( } #[cfg(test)] -mod tests { - use super::*; - - const FORGED_BUILDER_ID: &str = "https://invalid.test/builder"; - - #[test] - fn lock_entries_read_each_ecosystem() { - let npm = - r#"{"lockfileVersion":3,"packages":{"":{},"node_modules/a":{"version":"1.0.0"}}}"#; - let entries = lock_entries("npm", npm).expect("npm"); - assert_eq!(entries.len(), 1); - assert_eq!( - (entries[0].package.as_str(), entries[0].version.as_str()), - ("a", "1.0.0") - ); - let go = "module m\n\ngo 1.22\n\nrequire github.com/x/y v1.2.3\n"; - let entries = lock_entries("go", go).expect("go"); - assert_eq!(entries[0].set, "go"); - assert!(lock_entries("cargo", "").is_err()); - } - - #[test] - fn lock_entries_render_as_package_urls() { - let go = "module m\n\ngo 1.22\n\nrequire github.com/x/y v1.2.3\n"; - let entries = lock_entries("go", go).expect("go"); - let text = render_spdx_with_crates("a", "00", "a", "s", &entries); - assert!(text.contains("pkg:golang/github.com/x/y@v1.2.3"), "{text}"); - } - - fn scratch_dir() -> tempfile::TempDir { - tempfile::TempDir::new().expect("scratch") - } - - fn write_artifact(dir: &Path, name: &str, data: &[u8]) -> std::path::PathBuf { - let path = dir.join(name); - std::fs::write(&path, data).expect("write artifact"); - path - } - - #[test] - fn basename_rejects_missing_name() { - assert!(basename(Path::new("/")).is_err()); - assert_eq!( - basename(Path::new("/tmp/artifact.bin")).expect("base"), - "artifact.bin" - ); - } - - #[test] - fn spdx_bytes_match_python_golden() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = render_spdx("artifact.bin", digest, "dx", "rules_dx"); - assert!(text.ends_with('\n')); - assert!(!text.ends_with("\r\n")); - let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON"); - assert_eq!(value["spdxVersion"], serde_json::json!("SPDX-2.3")); - assert_eq!(value["dataLicense"], serde_json::json!("CC0-1.0")); - assert_eq!(value["SPDXID"], serde_json::json!("SPDXRef-DOCUMENT")); - assert_eq!(value["name"], serde_json::json!("dx-artifact.bin")); - assert_eq!( - value["documentNamespace"], - serde_json::json!(format!( - "https://github.com/devhusdk/rules_dx/releases/artifact.bin-{digest}" - )) - ); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines[1], " \"SPDXID\": \"SPDXRef-DOCUMENT\","); - assert!(text.contains("\"filesAnalyzed\": false")); - assert!(text.contains(&format!("pkg:generic/dx@{digest}"))); - assert!(text.contains("Organization: rules_dx")); - assert!(!text.contains("verificationCode"), "{text}"); - assert!(!text.contains("packageVerificationCode"), "{text}"); - assert!(!text.contains("null"), "{text}"); - assert_eq!( - value["relationships"], - serde_json::json!([{ - "relatedSpdxElement": "SPDXRef-Package", - "relationshipType": "DESCRIBES", - "spdxElementId": "SPDXRef-DOCUMENT", - }]) - ); - } - - #[test] - fn spdx_lists_inventory_crates() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let manifest = "zed|cargo|2.0.0|MIT||\nalpha|cargo|1.0.0|MIT OR Apache-2.0||\n"; - let crates = parse_notice_manifest(manifest).expect("manifest"); - let text = render_spdx_with_crates("artifact.bin", digest, "dx", "rules_dx", &crates); - let value: serde_json::Value = serde_json::from_str(&text).expect("json"); - let packages = value["packages"].as_array().expect("packages"); - let ids: Vec<&str> = packages - .iter() - .map(|p| p["SPDXID"].as_str().unwrap()) - .collect(); - assert_eq!( - ids, - [ - "SPDXRef-Package", - "SPDXRef-Crate-alpha-1.0.0", - "SPDXRef-Crate-zed-2.0.0" - ] - ); - assert_eq!(packages[1]["licenseDeclared"], "MIT OR Apache-2.0"); - assert_eq!( - packages[2]["externalRefs"][0]["referenceLocator"], - "pkg:cargo/zed@2.0.0" - ); - let rels = value["relationships"].as_array().expect("relationships"); - assert_eq!(rels.len(), 3); - assert_eq!(rels[1]["relationshipType"], "CONTAINS"); - assert_eq!(rels[2]["relatedSpdxElement"], "SPDXRef-Crate-zed-2.0.0"); - assert_eq!(dx_sbom_schema::spdx_errors(&text), Vec::::new()); - } - - #[test] - fn spdx_lists_runtimes_with_file_hashes() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let record = |name: &str, redistribution: &str, files: Vec| { - runtimes::RuntimeRecord { - name: name.to_owned(), - version: "1.2".to_owned(), - redistribution: redistribution.to_owned(), - source: "fixture".to_owned(), - files, - notice: None, - } - }; - let records = [ - record("libc", "os_supplied", Vec::new()), - record( - "libz", - "app_local", - vec![runtimes::RuntimeFile { - name: "libz.so".to_owned(), - sha256: "ab".repeat(32), - }], - ), - ]; - let text = render_spdx_full("artifact.bin", digest, "dx", "rules_dx", &[], &records); - let value: serde_json::Value = serde_json::from_str(&text).expect("json"); - let packages = value["packages"].as_array().expect("packages"); - assert_eq!(packages.len(), 3); - assert_eq!(packages[1]["name"], "libc"); - assert_eq!(packages[1]["supplier"], "Organization: fixture"); - assert!( - packages[1]["comment"] - .as_str() - .unwrap() - .contains("operating system") - ); - assert_eq!(packages[2]["versionInfo"], "1.2"); - let files = value["files"].as_array().expect("files"); - assert_eq!(files.len(), 2); - assert_eq!(files[1]["fileName"], "libz.so"); - assert_eq!(files[1]["checksums"][0]["checksumValue"], "ab".repeat(32)); - let rels = value["relationships"].as_array().expect("relationships"); - assert_eq!(rels.len(), 4); - assert_eq!(rels[3]["spdxElementId"], "SPDXRef-Runtime-libz-1.2"); - assert_eq!(rels[3]["relationshipType"], "CONTAINS"); - assert_eq!(dx_sbom_schema::spdx_errors(&text), Vec::::new()); - assert_eq!( - render_spdx_full("artifact.bin", digest, "dx", "rules_dx", &[], &[]), - render_spdx("artifact.bin", digest, "dx", "rules_dx") - ); - } - - #[test] - fn release_spdx_passes_spdx_2_3_schema() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = render_spdx("artifact.bin", digest, "dx", "rules_dx"); - assert_eq!(dx_sbom_schema::spdx_errors(&text), Vec::::new()); - } - - #[test] - fn provenance_bytes_match_python_golden() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = render_provenance("artifact.bin", digest, PROVENANCE_BUILDER_RELEASE); - assert!(text.ends_with('\n')); - let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON"); - assert_eq!( - value["_type"], - serde_json::json!("https://in-toto.io/Statement/v1") - ); - assert_eq!( - value["predicateType"], - serde_json::json!("https://slsa.dev/provenance/v1") - ); - assert_eq!( - value["subject"][0]["digest"]["sha256"], - serde_json::json!(digest) - ); - assert_eq!( - value["subject"][0]["name"], - serde_json::json!("artifact.bin") - ); - assert_eq!( - value["predicate"]["runDetails"]["builder"]["id"], - serde_json::json!(PROVENANCE_BUILDER_RELEASE) - ); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!( - lines[1], - " \"_type\": \"https://in-toto.io/Statement/v1\"," - ); - } - - #[test] - fn bcr_bytes_match_python_golden() { - let text = render_bcr_source("rules_dx", "1.2.3"); - assert_eq!( - text, - "{\n \"integrity\": \"\",\n \"strip_prefix\": \"rules_dx-1.2.3\",\n \"url\": \"https://github.com/devhusdk/rules_dx/releases/download/v1.2.3/rules_dx-1.2.3.tar.gz\"\n}\n" - ); - } - - #[test] - fn bcr_family_source_points_at_the_core_archive() { - let text = render_bcr_source("rules_dx_android", "1.2.3"); - assert_eq!( - text, - "{\n \"integrity\": \"\",\n \"strip_prefix\": \"rules_dx-1.2.3/families/android\",\n \"url\": \"https://github.com/devhusdk/rules_dx/releases/download/v1.2.3/rules_dx-1.2.3.tar.gz\"\n}\n" - ); - } - - #[test] - fn render_escapes_non_ascii_like_python() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = render_spdx("artifact.bin", digest, "dx", "caf\u{e9}"); - assert!(text.contains("caf\\u00e9")); - assert!(!text.contains("caf\u{e9}")); - let astral = render_bcr_source("rules_dx\u{1F600}", "1.2.3"); - assert!(astral.contains("\\ud83d\\ude00")); - assert_eq!(dx_fingerprint::ensure_ascii("caf\u{e9}"), "caf\\u00e9"); - let value = serde_json::json!({"name": "caf\u{e9}"}); - let central = dx_fingerprint::to_json_ascii_pretty(&value).expect("central pretty"); - assert!(central.contains("caf\\u00e9")); - assert_eq!(render_pretty(&value), central); - } - - #[test] - fn write_helpers_round_trip_digests() { - let scratch = scratch_dir(); - let src = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let digest = dx_digest::sha256_file_hex(&src).expect("digest"); - let spdx_out = scratch.path().join("out.spdx.json"); - write_spdx(&src, &spdx_out, "dx", "rules_dx").expect("write spdx"); - let spdx_text = std::fs::read_to_string(&spdx_out).expect("read spdx"); - assert_eq!( - spdx_text, - render_spdx("artifact.bin", &digest, "dx", "rules_dx") - ); - let prov_out = scratch.path().join("out.prov.json"); - write_provenance(&src, &prov_out, PROVENANCE_BUILDER_RELEASE).expect("write prov"); - let prov_text = std::fs::read_to_string(&prov_out).expect("read prov"); - assert_eq!( - prov_text, - render_provenance("artifact.bin", &digest, PROVENANCE_BUILDER_RELEASE) - ); - let bcr_out = scratch.path().join("out.source.json"); - write_bcr_source(&bcr_out, "rules_dx", "1.2.3").expect("write bcr"); - assert_eq!( - std::fs::read_to_string(&bcr_out).expect("read bcr"), - render_bcr_source("rules_dx", "1.2.3") - ); - assert!(write_spdx(&scratch.path().join("missing"), &spdx_out, "dx", "s").is_err()); - assert!(write_provenance(&scratch.path().join("missing"), &prov_out, "b").is_err()); - } - - #[test] - fn bcr_dry_run_matches_shell() { - let out = render_bcr_dry_run("rules_dx", "0.0.0", &["/tmp/a.spdx.json".to_owned()]); - assert!(out.contains("bcr: dry run (BCR_DRY_RUN=1); would submit, submitting nothing:")); - assert!(out.contains(" module: rules_dx")); - assert!(out.contains(" version: 0.0.0")); - assert!(out.contains(" input: a.spdx.json (/tmp/a.spdx.json)")); - assert!(out.contains("gh pr create --repo bazelbuild/bazel-central-registry")); - assert!(out.contains("presubmit: bazel test @rules_dx//...")); - let ok = bcr_run("rules_dx", "0.0.0", &[], true, false).expect("dry"); - assert!(ok.contains("would submit, submitting nothing")); - assert!(bcr_run("other", "0.0.0", &[], true, false).is_err()); - assert!(bcr_run("rules_dx", "0.0.0", &[], false, false).is_err()); - let err = bcr_run("rules_dx", "0.0.0", &[], false, true).expect_err("0.0.0"); - assert!(err.contains("unpublishable (shape check only)")); - assert!(bcr_run("rules_dx", "1.2.3", &[], false, false).is_err()); - let live = - bcr_run("rules_dx", "1.2.3", &["/x/y.json".to_owned()], false, true).expect("live"); - assert!(live.contains("\"schema\": \"release-ledger/v1\"")); - assert!(live.contains("\"subject\": \"rules_dx@1.2.3\"")); - assert!(live.contains("\"state\": \"validated\"")); - assert!(live.contains("never pushes itself")); - } - - #[test] - fn bcr_run_accepts_family_modules_and_rejects_other_names() { - let ok = bcr_run("rules_dx_android", "0.0.0", &[], true, false).expect("family dry run"); - assert!(ok.contains(" module: rules_dx_android\n")); - for module in ["rules_dxx", "rules_dx_", "rules_dx-android", "other"] { - let err = bcr_run(module, "0.0.0", &[], true, false).expect_err(module); - assert!( - err.contains("want 'rules_dx' or 'rules_dx_'"), - "{module}: {err}" - ); - } - } - - #[test] - fn bcr_submit_record_needs_success_and_a_bcr_pull_request() { - let url = "https://github.com/bazelbuild/bazel-central-registry/pull/123"; - let ok = bcr_submit_record("rules_dx@1.2.3", "success", url); - assert_eq!(ok.state, OpState::Executed); - assert_eq!(ok.detail, url); - for (result, url) in [ - ("failure", url), - ("cancelled", url), - ("success", ""), - ("success", "https://example.com/pull/1"), - ] { - let record = bcr_submit_record("rules_dx@1.2.3", result, url); - assert_eq!(record.state, OpState::Failed, "{result} {url}"); - } - assert!( - bcr_submit_record("s", "failure", "") - .detail - .contains("failure") - ); - } - - #[test] - fn signing_dry_run_matches_shell() { - let assets = ["/tmp/a.bin".to_owned()]; - let pin = CosignPin { - path: "/nonexistent/cosign", - version: "v9.8.7", - sha256: "0", - }; - let outdir = Path::new("/out"); - let out = render_signing_dry_run("IDENT", "ISSUER", &assets, pin.version, outdir); - assert!(out.contains( - "signing: dry run (RELEASE_SIGN_DRY_RUN=1); would sign, publishing nothing:" - )); - assert!(out.contains(&format!("trust root: {SIGNING_TRUST_ROOT}"))); - assert!(out.contains("cosign sign-blob --yes --bundle /out/a.bin.bundle /tmp/a.bin")); - assert!(out.contains("gh attestation create /tmp/a.bin --bundle /out/a.bin.bundle")); - assert!(out.contains("cosign verify-blob")); - assert!(out.contains("cosign: v9.8.7 ")); - assert!(out.contains(SIGNING_BUNDLE_MEDIA_TYPE)); - assert!(signing_run("", "ISSUER", &assets, true, &pin, outdir).is_err()); - assert!(signing_run("ID", "ISS", &[], true, &pin, outdir).is_err()); - let err = - signing_run("ID", "ISS", &assets, false, &pin, outdir).expect_err("missing cosign"); - assert!( - err.contains("cannot hash cosign at /nonexistent/cosign"), - "got {err}" - ); - let ok = signing_run("ID", "ISS", &assets, true, &pin, outdir).expect("dry"); - assert!(ok.contains("would sign, publishing nothing")); - } - - #[test] - fn release_dry_run_matches_shell() { - let out = render_release_dry_run("v0.0.0-dryrun", "0"); - assert!(out.contains("release: dry run (RELEASE_DRY_RUN=1)")); - assert!(out.contains("never creates or pushes tags")); - assert!(out.contains("publishing nothing")); - assert!(out.contains("dx_verify")); - assert!(out.contains("notice_demo")); - assert!(out.contains("release_artifacts")); - assert!(out.contains("subject digest equal to artifact sha256")); - assert!(out.contains("signed alongside the SBOM pair")); - let package = out.find("release_artifacts").expect("packaging step"); - let notice = out.find("notice_demo").expect("notice step"); - let sign = out.find("signing_demo").expect("sign step"); - let draft = out.find("github_draft").expect("draft step"); - assert!(package < notice); - assert!(notice < sign); - assert!(sign < draft); - let ok = release_run("v0.0.0-dryrun", None, "0", true, git_unread).expect("dry"); - assert!(ok.contains("would release, publishing nothing")); - assert!(release_run("v1.2.3", Some(SHA), "0", false, git_unread).is_err()); - assert!(release_run("v0.0.0-dryrun", Some(SHA), "1", false, git_unread).is_err()); - assert_eq!( - release_live_records("v1.2.3", Some(SHA), "1", || git_ok(true, SHA, Some(SHA))), - Ok(vec![OpRecord::new( - "tag-local", - "v1.2.3", - OpState::Failed, - "release: tree is dirty; release requires a clean tree" - )]) - ); - assert_eq!( - release_live_records("v1.2.3", Some(SHA), "1", || git_ok(false, SHA, None)), - Ok(vec![OpRecord::new( - "tag-local", - "v1.2.3", - OpState::Failed, - "release: local tag v1.2.3 missing; push it beforehand with owner approval (this program never creates tags)" - )]) - ); - let live = release_run("v1.2.3", Some(SHA), "1", false, || { - git_ok(false, SHA, Some(SHA)) - }) - .expect("live"); - assert!(live.contains("release-ledger/v1")); - } - - const SHA: &str = "a948904f2f0f479b8f8197694b30184b0d2ed1c1"; - const OTHER: &str = "b948904f2f0f479b8f8197694b30184b0d2ed1c1"; - - #[test] - fn release_run_needs_a_valid_candidate_before_reading_git() { - let error = release_run("v1.2.3", None, "1", false, git_unread).expect_err("no commit"); - assert!(error.contains("needs the candidate commit"), "{error}"); - let error = - release_run("1.2.3", Some(SHA), "1", false, git_unread).expect_err("no v prefix"); - assert!(error.contains("must be v"), "{error}"); - let error = - release_run("v1.2", Some(SHA), "1", false, git_unread).expect_err("bad version"); - assert!(error.contains("invalid version '1.2'"), "{error}"); - let error = - release_run("v1.2.3", Some("a948904"), "1", false, git_unread).expect_err("short"); - assert!(error.contains("invalid commit"), "{error}"); - let upper = SHA.to_uppercase(); - let error = release_run("v1.2.3", Some(&upper), "1", false, git_unread).expect_err("upper"); - assert!(error.contains("invalid commit"), "{error}"); - } - - #[test] - fn release_run_rejects_a_stale_head() { - assert_eq!( - release_live_records("v1.2.3", Some(SHA), "1", || git_ok(false, OTHER, Some(SHA))), - Ok(vec![OpRecord::new( - "tag-local", - "v1.2.3", - OpState::Failed, - &format!( - "release: HEAD is {OTHER}, candidate is {SHA}; check out the candidate commit" - ) - )]) - ); - } - - #[test] - fn release_run_rejects_a_local_tag_pointing_elsewhere() { - assert_eq!( - release_live_records("v1.2.3", Some(SHA), "1", || git_ok(false, SHA, Some(OTHER))), - Ok(vec![OpRecord::new( - "tag-local", - "v1.2.3", - OpState::Failed, - &format!("release: local tag v1.2.3 points at {OTHER}, candidate is {SHA}") - )]) - ); - } - - fn git_unread() -> Result { - panic!("git state read before the dry-run, approval, or placeholder gate") - } - - #[test] - #[should_panic(expected = "git state read")] - fn release_reads_git_after_the_gates() { - let _ = release_run("v1.2.3", Some(SHA), "1", false, git_unread); - } - - fn git_ok(tree_dirty: bool, head: &str, tag: Option<&str>) -> Result { - Ok(GitState { - tree_dirty, - head: head.to_owned(), - tag_commit: tag.map(str::to_owned), - }) - } - - #[test] - fn release_git_error_blocks_live_release() { - assert_eq!( - release_live_records("v1.2.3", Some(SHA), "1", || Err( - "release: cannot read git state: x".to_owned() - )), - Ok(vec![OpRecord::new( - "tag-local", - "v1.2.3", - OpState::Failed, - "release: cannot read git state: x" - )]) - ); - } - - #[test] - fn release_live_prints_one_ledger() { - let live = release_run("v1.2.3", Some(SHA), "1", false, || { - git_ok(false, SHA, Some(SHA)) - }) - .expect("live"); - let value: serde_json::Value = serde_json::from_str(&live).expect("json"); - assert_eq!(value["schema"], "release-ledger/v1"); - assert_eq!(value["records"][0]["operation"], "tag-local"); - assert_eq!(value["records"][0]["state"], "validated"); - assert!(live.contains(&format!("clean tree, HEAD and local tag at {SHA}"))); - assert!(!live.contains("executed no release step")); - } - - #[test] - fn release_remote_record_validates_or_fails() { - let candidate = candidate::Candidate::new("1.2.3", SHA).expect("candidate"); - let ok = release_remote_record(&candidate, Ok(format!("{SHA}\trefs/tags/v1.2.3\n"))); - assert_eq!(ok.operation, "tag-remote"); - assert_eq!(ok.state, OpState::Validated); - let bad = release_remote_record(&candidate, Ok(format!("{OTHER}\trefs/tags/v1.2.3\n"))); - assert_eq!(bad.state, OpState::Failed); - assert!(bad.detail.contains("points at")); - let err = release_remote_record(&candidate, Err("fatal: offline".to_owned())); - assert_eq!(err.state, OpState::Failed); - assert!(err.detail.contains("fatal: offline")); - } - - #[test] - fn base64_encodes_short_inputs_with_padding() { - assert_eq!(base64_encode(b""), ""); - assert_eq!(base64_encode(b"f"), "Zg=="); - assert_eq!(base64_encode(b"fo"), "Zm8="); - assert_eq!(base64_encode(b"foo"), "Zm9v"); - assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); - assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); - assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); - } - - #[test] - fn bcr_integrity_comes_from_the_archive_sha256() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - assert_eq!( - bcr_integrity_of_digest(digest).expect("integrity"), - "sha256-qUiQTy8PR5uPgZdpSzAYSw0u0cHNKh7A+4XSmaGSpEc=" - ); - assert!(bcr_integrity_of_digest("").is_err()); - assert!(bcr_integrity_of_digest("xyz").is_err()); - assert!(bcr_integrity_of_digest(&"0".repeat(63)).is_err()); - assert!(bcr_integrity_of_digest(&"0".repeat(64)).is_ok()); - assert!( - bcr_integrity_of_digest( - "A948904F2F0F479B8F8197694B30184B0D2ED1C1CD2A1EC0FB85D299A192A447" - ) - .is_err() - ); - } - - #[test] - fn candidate_source_metadata_comes_from_one_sha() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = - render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); - assert_eq!( - text, - "{\n \"integrity\": \"sha256-qUiQTy8PR5uPgZdpSzAYSw0u0cHNKh7A+4XSmaGSpEc=\",\n \"strip_prefix\": \"rules_dx-1.2.3-rc1\",\n \"url\": \"https://github.com/devhusdk/rules_dx/releases/download/v1.2.3-rc1/rules_dx-1.2.3-rc1.tar.gz\"\n}\n" - ); - bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", digest).expect("self-validates"); - assert_eq!( - bcr_source_url("rules_dx", "1.2.3"), - "https://github.com/devhusdk/rules_dx/releases/download/v1.2.3/rules_dx-1.2.3.tar.gz" - ); - assert_eq!(bcr_strip_prefix("rules_dx", "1.2.3"), "rules_dx-1.2.3"); - } - - #[test] - fn candidate_metadata_rejects_shape_versions_and_foreign_modules() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - assert!(render_bcr_source_for_digest("other", "1.2.3", digest).is_err()); - assert!(render_bcr_source_for_digest("rules_dx", "0.0.0", digest).is_err()); - for bad in ["", "v1", "1.2", "1.2.3.4", "1..3", ".2.3"] { - assert!( - render_bcr_source_for_digest("rules_dx", bad, digest).is_err(), - "version {bad:?} must fail" - ); - } - } - - #[test] - fn candidate_source_rejects_a_swapped_archive_digest() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = - render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); - let zeros = "0".repeat(64); - let error = - bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", &zeros).expect_err("swapped"); - assert!(error.contains("integrity mismatch"), "got {error}"); - assert!(error.contains("immutable version artifacts"), "got {error}"); - } - - #[test] - fn candidate_source_rejects_wrong_prefix_and_url() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = - render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); - let error = - bcr_candidate_error(&text, "rules_dx", "1.2.4", digest).expect_err("wrong version"); - assert!(error.contains("strip_prefix mismatch"), "got {error}"); - let other_module = - render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); - let error = bcr_candidate_error(&other_module, "other_rules", "1.2.3-rc1", digest) - .expect_err("wrong module"); - assert!(error.contains("strip_prefix mismatch"), "got {error}"); - let tampered = text.replace("v1.2.3-rc1/rules_dx-1.2.3-rc1", "v9.9.9/rules_dx-9.9.9"); - let error = - bcr_candidate_error(&tampered, "rules_dx", "1.2.3-rc1", digest).expect_err("tampered"); - assert!(error.contains("url mismatch"), "got {error}"); - } - - #[test] - fn interrupted_candidate_publication_fails_closed() { - let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; - let text = - render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); - let cut = text.len() / 2; - let error = bcr_candidate_error(&text[..cut], "rules_dx", "1.2.3-rc1", digest) - .expect_err("truncated"); - assert!(error.contains("not JSON"), "got {error}"); - let error = - bcr_candidate_error("{}", "rules_dx", "1.2.3-rc1", digest).expect_err("empty object"); - assert!(error.contains("integrity mismatch"), "got {error}"); - let error = - bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", "not-hex").expect_err("bad digest"); - assert!(error.contains("invalid sha256 digest"), "got {error}"); - } - - #[test] - fn write_candidate_source_round_trips_the_archive() { - let scratch = scratch_dir(); - let src = write_artifact(scratch.path(), "candidate.tar.gz", b"candidate bytes\n"); - let digest = dx_digest::sha256_file_hex(&src).expect("digest"); - let dst = scratch.path().join("candidate.source.json"); - let integrity = - write_bcr_source_for_archive(&dst, &modules::ReleaseModule::core(), "1.2.3-rc1", &src) - .expect("write"); - assert_eq!( - integrity, - bcr_integrity_of_digest(&digest).expect("integrity") - ); - let text = std::fs::read_to_string(&dst).expect("read"); - bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", &digest).expect("validates"); - assert!( - write_bcr_source_for_archive(&dst, &modules::ReleaseModule::core(), "0.0.0", &src) - .is_err() - ); - assert!( - write_bcr_source_for_archive( - &dst, - &modules::ReleaseModule::core(), - "1.2.3-rc1", - &scratch.path().join("missing") - ) - .is_err() - ); - } - - #[test] - fn remote_tag_error_fails_closed_without_network_or_tag() { - let candidate = candidate::Candidate::new("1.2.3", SHA).expect("candidate"); - let output = format!("{SHA}\trefs/tags/v1.2.3\n"); - assert_eq!( - release_remote_error(&candidate, Ok(output.clone())).expect("remote"), - SHA - ); - let error = release_remote_error(&candidate, Err("fatal: unable to access".to_owned())) - .expect_err("no network"); - assert!( - error.contains("cannot verify remote tag v1.2.3"), - "got {error}" - ); - assert!(error.contains("fatal: unable to access"), "got {error}"); - assert!(error.contains(RELEASE_REMOTE), "got {error}"); - assert!( - error.contains("never creates or pushes tags"), - "got {error}" - ); - let other = candidate::Candidate::new("9.9.9", SHA).expect("candidate"); - let error = release_remote_error(&other, Ok(output)).expect_err("missing tag"); - assert!(error.contains("remote has no tag v9.9.9"), "got {error}"); - assert!(!error.contains("origin"), "got {error}"); - } - - #[test] - fn remote_tag_error_rejects_a_tag_pointing_elsewhere() { - let candidate = candidate::Candidate::new("1.2.3", SHA).expect("candidate"); - let output = format!("{OTHER}\trefs/tags/v1.2.3\n"); - assert_eq!( - release_remote_error(&candidate, Ok(output)), - Err(format!( - "release: remote tag v1.2.3 points at {OTHER}, candidate is {SHA} (git ls-remote --tags {RELEASE_REMOTE})" - )) - ); - let error = - release_remote_error(&candidate, Ok("garbage\n".to_owned())).expect_err("malformed"); - assert!(error.contains("malformed"), "got {error}"); - } - - #[test] - fn release_identity_comes_from_one_repository() { - assert_eq!(RELEASE_REPOSITORY, "https://github.com/devhusdk/rules_dx"); - assert_eq!(RELEASE_REMOTE, "https://github.com/devhusdk/rules_dx.git"); - assert_eq!( - PROVENANCE_BUILDER_RELEASE, - "https://github.com/devhusdk/rules_dx/.github/workflows/release.yml" - ); - } - - #[test] - fn sbom_verify_binds_digest() { - let scratch = scratch_dir(); - let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); - let spdx = scratch.path().join("artifact.spdx.json"); - write_spdx(&artifact, &spdx, "dx", "rules_dx").expect("spdx"); - let prov = scratch.path().join("artifact.prov.json"); - write_provenance(&artifact, &prov, PROVENANCE_BUILDER_RELEASE).expect("prov"); - let ok = - sbom_verify_files(&artifact, &spdx, &prov, PROVENANCE_BUILDER_RELEASE).expect("verify"); - assert!(ok.contains("sbom OK: SPDX-2.3 + SLSA v1 bind")); - assert!(ok.contains(&digest)); - std::fs::write(&spdx, b"{\"spdxVersion\": \"SPDX-3.0\"}").expect("rewrite"); - assert!(sbom_verify_files(&artifact, &spdx, &prov, PROVENANCE_BUILDER_RELEASE).is_err()); - } - - #[test] - fn sbom_verify_reads_spdx_fields_not_text() { - let scratch = scratch_dir(); - let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); - let spdx_path = scratch.path().join("artifact.spdx.json"); - write_spdx(&artifact, &spdx_path, "dx", "rules_dx").expect("spdx"); - let prov = scratch.path().join("artifact.prov.json"); - write_provenance(&artifact, &prov, PROVENANCE_BUILDER_RELEASE).expect("prov"); - let value: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&spdx_path).expect("read")) - .expect("json"); - let compact = serde_json::to_string(&value).expect("compact"); - assert!( - !compact.contains("\"spdxVersion\": \"SPDX-2.3\""), - "compact differs" - ); - let others = scratch.path().join("artifact.prov.json"); - for (label, text, ok) in [ - ("reordered", reordered_spdx(&value), true), - ("compact", compact, true), - ( - "reordered wrong digest", - reordered_spdx(&replaced_digest(&value, &digest, &"0".repeat(64))), - false, - ), - ] { - std::fs::write(&spdx_path, text.as_bytes()).expect("rewrite"); - let result = - sbom_verify_files(&artifact, &spdx_path, &others, PROVENANCE_BUILDER_RELEASE); - assert_eq!(result.is_ok(), ok, "{label}: {result:?}"); - } - } - - /// Renders a document with its top-level keys in reverse order and compact values. - fn reordered_spdx(value: &serde_json::Value) -> String { - let entries: Vec = value - .as_object() - .expect("object") - .iter() - .rev() - .map(|(key, field)| { - format!( - " {key:?}: {}", - serde_json::to_string(field).expect("field") - ) - }) - .collect(); - format!("{{\n{}\n}}", entries.join(",\n")) - } - - #[test] - fn sbom_verify_reads_provenance_fields_not_text() { - let scratch = scratch_dir(); - let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); - let spdx = scratch.path().join("artifact.spdx.json"); - write_spdx(&artifact, &spdx, "dx", "rules_dx").expect("spdx"); - let prov = scratch.path().join("artifact.prov.json"); - let base = render_provenance("artifact.bin", &digest, PROVENANCE_BUILDER_RELEASE); - let value: serde_json::Value = serde_json::from_str(&base).expect("json"); - let with = |edit: &dyn Fn(&mut serde_json::Value)| { - let mut copy = value.clone(); - edit(&mut copy); - serde_json::to_string(©).expect("compact") - }; - let release = PROVENANCE_BUILDER_RELEASE; - let evil = format!("{release}.evil"); - let duplicate = base.replacen( - "\"_type\"", - "\"_type\": \"https://in-toto.io/Statement/v1\",\n \"_type\"", - 1, - ); - let cases: Vec<(&str, String, &str, Result<(), &str>)> = vec![ - ("compact", with(&|_| {}), PROVENANCE_BUILDER_RELEASE, Ok(())), - ( - "reordered", - reordered_spdx(&value), - PROVENANCE_BUILDER_RELEASE, - Ok(()), - ), - ( - "prefix builder", - with(&|v| v["predicate"]["runDetails"]["builder"]["id"] = evil.clone().into()), - release, - Err("is not an allowlisted"), - ), - ( - "builder only in externalParameters", - with(&|v| { - v["predicate"]["runDetails"]["builder"]["id"] = "https://evil.example".into(); - v["predicate"]["buildDefinition"]["externalParameters"]["b"] = release.into(); - }), - release, - Err("is not an allowlisted"), - ), - ( - "digest only in externalParameters", - with(&|v| { - v["predicate"]["buildDefinition"]["externalParameters"]["d"] = - digest.clone().into(); - v["subject"][0]["digest"]["sha256"] = "0".repeat(64).into(); - }), - PROVENANCE_BUILDER_RELEASE, - Err("missing subject digest"), - ), - ( - "other subject name", - with(&|v| v["subject"][0]["name"] = "other.bin".into()), - PROVENANCE_BUILDER_RELEASE, - Err("missing subject digest"), - ), - ( - "missing _type", - with(&|v| { - v.as_object_mut().expect("object").remove("_type"); - }), - PROVENANCE_BUILDER_RELEASE, - Err("missing in-toto Statement v1"), - ), - ( - "not json", - "not json".to_owned(), - PROVENANCE_BUILDER_RELEASE, - Err("not an in-toto statement"), - ), - ( - "duplicate _type", - duplicate, - PROVENANCE_BUILDER_RELEASE, - Err("not an in-toto statement"), - ), - ]; - for (label, text, builder, want) in cases { - std::fs::write(&prov, text.as_bytes()).expect("write"); - let result = sbom_verify_files(&artifact, &spdx, &prov, builder); - match want { - Ok(()) => assert!(result.is_ok(), "{label}: {result:?}"), - Err(part) => { - let err = result.expect_err(label).to_string(); - assert!(err.contains(part), "{label}: want {part}, got {err}"); - } - } - } - } - - fn replaced_digest( - value: &serde_json::Value, - digest: &str, - replacement: &str, - ) -> serde_json::Value { - let text = serde_json::to_string(value).expect("compact"); - serde_json::from_str(&text.replace(digest, replacement)).expect("json") - } - - #[test] - fn provenance_builder_allowlist_rejects_forgeries() { - assert!(provenance_builder_error(PROVENANCE_BUILDER_RELEASE).is_ok()); - assert!(provenance_builder_error(FORGED_BUILDER_ID).is_err()); - assert!(provenance_builder_error("https://example.com/builder").is_err()); - assert!(provenance_builder_error("").is_err()); - let scratch = scratch_dir(); - let src = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let dst = scratch.path().join("out.prov.json"); - assert!(write_provenance(&src, &dst, FORGED_BUILDER_ID).is_err()); - assert!(!dst.exists()); - } - - #[test] - fn sbom_verify_rejects_forged_builder() { - let scratch = scratch_dir(); - let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); - let spdx = scratch.path().join("artifact.spdx.json"); - write_spdx(&artifact, &spdx, "dx", "rules_dx").expect("spdx"); - let prov = scratch.path().join("forged.prov.json"); - let forged = render_provenance("artifact.bin", &digest, FORGED_BUILDER_ID); - std::fs::write(&prov, forged.as_bytes()).expect("write forged"); - assert!(sbom_verify_files(&artifact, &spdx, &prov, PROVENANCE_BUILDER_RELEASE).is_err()); - } - - #[test] - fn signing_argv_pins_bundle_and_version() { - let sign = signing_sign_argv("/pin/cosign", "/tmp/a.bin", "a.bin.bundle"); - assert_eq!( - sign, - vec![ - "/pin/cosign", - "sign-blob", - "--yes", - "--new-bundle-format", - "--bundle", - "a.bin.bundle", - "/tmp/a.bin", - ] - ); - let verify = signing_verify_argv( - "/pin/cosign", - "/tmp/a.bin", - "a.bin.bundle", - "IDENT", - "ISSUER", - ); - assert_eq!( - verify, - vec![ - "/pin/cosign", - "verify-blob", - "--bundle", - "a.bin.bundle", - "--certificate-identity", - "IDENT", - "--certificate-oidc-issuer", - "ISSUER", - "/tmp/a.bin", - ] - ); - let real = - " ______ ______\nGitVersion: v9.8.7\nGitCommit: abc\nGoVersion: go1.25\n"; - assert_eq!(cosign_git_version(real), Some("v9.8.7")); - assert!(signing_version_ok(real, "v9.8.7")); - assert!(!signing_version_ok("GitVersion: v9.8.70\n", "v9.8.7")); - assert!(!signing_version_ok("GitVersion: v9.8.6\n", "v9.8.7")); - assert!(!signing_version_ok( - "cosign version v9.8.7 (go1.25)\n", - "v9.8.7" - )); - assert!(!signing_version_ok( - "GitCommit: v9.8.7\nGitVersion: v9.8.8\n", - "v9.8.7" - )); - assert!(!signing_version_ok("", "v9.8.7")); - } - - const FIXTURE_WORDS_A: &str = "Fixture words for demo-lib-a (MIT placeholder).\n"; - const FIXTURE_WORDS_B: &str = "Fixture words for demo-lib-b (Apache-2.0 placeholder).\n"; - - fn fixture_manifest() -> String { - format!( - "demo-lib-b|npm|2.3.4|Apache-2.0|demo-lib-b.txt|{}\ndemo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", - notice_text_sha256(FIXTURE_WORDS_B), - notice_text_sha256(FIXTURE_WORDS_A) - ) - } - - fn notice_fixture(dir: &Path) -> (std::path::PathBuf, Vec) { - let manifest = dir.join("inventory.txt"); - std::fs::write(&manifest, fixture_manifest()).expect("manifest"); - let a = dir.join("demo-lib-a.txt"); - std::fs::write(&a, FIXTURE_WORDS_A).expect("words a"); - let b = dir.join("demo-lib-b.txt"); - std::fs::write(&b, FIXTURE_WORDS_B).expect("words b"); - (manifest, vec![a, b]) - } - - #[test] - fn notice_manifest_parses_and_sorts() { - let entries = parse_notice_manifest(&format!( - "# invented distributed-tier root\n\n{}", - fixture_manifest() - )) - .expect("parse"); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].package, "demo-lib-a"); - assert_eq!(entries[1].package, "demo-lib-b"); - assert!(parse_notice_manifest("only|four|fields|here|x").is_err()); - assert!( - parse_notice_manifest(&format!("|npm|1.0.0|MIT|a.txt|{}", "0".repeat(64))).is_err() - ); - } - - #[test] - fn notice_render_is_deterministic_over_input_order() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - let manifest_text = std::fs::read_to_string(&manifest).expect("read"); - let first = join_notice_entries(&manifest_text, &texts).expect("join"); - let mut rev = texts.clone(); - rev.reverse(); - let second = join_notice_entries(&manifest_text, &rev).expect("join rev"); - assert_eq!( - render_notice("//demo:root", &first), - render_notice("//demo:root", &second) - ); - let text = render_notice("//demo:root", &first); - assert!(text.starts_with("NOTICE for //demo:root\n")); - assert!(text.ends_with('\n')); - assert!(!text.ends_with("\r\n")); - let a = text - .find("=== demo-lib-a 1.0.0 (MIT) [cargo] ===") - .expect("a header"); - let b = text - .find("=== demo-lib-b 2.3.4 (Apache-2.0) [npm] ===") - .expect("b header"); - assert!(a < b); - assert!(text.contains("Fixture words for demo-lib-a")); - } - - #[test] - fn notice_write_rebuilds_byte_identical() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - let first = scratch.path().join("first.NOTICE"); - write_notice(&manifest, &first, "//demo:root", &texts, None).expect("first"); - let second = scratch.path().join("second.NOTICE"); - write_notice(&manifest, &second, "//demo:root", &texts, None).expect("second"); - assert_eq!( - std::fs::read(&first).expect("read first"), - std::fs::read(&second).expect("read second") - ); - let ok = notice_verify_files(&first, &manifest, &texts).expect("verify"); - assert!(ok.contains("notice OK: 2 entries bundled")); - } - - #[test] - fn notice_missing_text_fails_actionable() { - let scratch = scratch_dir(); - let manifest = scratch.path().join("inventory.txt"); - std::fs::write(&manifest, "demo-lib-a|cargo|1.0.0|MIT||\n").expect("manifest"); - let out = scratch.path().join("out.NOTICE"); - let err = write_notice(&manifest, &out, "//demo:root", &[], None).expect_err("missing"); - assert!(err.to_string().contains("missing-notice-text")); - let (good_manifest, texts) = notice_fixture(scratch.path()); - let notice = scratch.path().join("good.NOTICE"); - write_notice(&good_manifest, ¬ice, "//demo:root", &texts, None).expect("good"); - std::fs::write(¬ice, "tampered bytes\n").expect("tamper"); - let err = notice_verify_files(¬ice, &good_manifest, &texts).expect_err("tampered"); - assert!(err.to_string().contains("NOTICE")); - } - - #[test] - fn bin_shims_share_failure_rendering() { - assert_eq!( - bin_usage("release_tool spdx", " "), - 1 - ); - assert_eq!( - bin_cannot_write( - "release_tool notice", - Path::new("/nope/notice.NOTICE"), - "read-only filesystem" - ), - 1 - ); - assert_eq!(bin_error("release: owner approval missing"), 1); - } - - #[test] - fn write_spdx_fails_when_target_unwritable() { - let scratch = scratch_dir(); - let src = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - assert!(write_spdx(&src, scratch.path(), "dx", "rules_dx").is_err()); - } - - #[test] - fn sbom_verify_rejects_broken_bindings() { - let scratch = scratch_dir(); - let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); - let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); - let zeros = "0".repeat(digest.len()); - let spdx_ok = scratch.path().join("ok.spdx.json"); - write_spdx(&artifact, &spdx_ok, "dx", "rules_dx").expect("spdx ok"); - let prov_ok = scratch.path().join("ok.prov.json"); - write_provenance(&artifact, &prov_ok, PROVENANCE_BUILDER_RELEASE).expect("prov ok"); - let spdx_bad = scratch.path().join("bad.spdx.json"); - std::fs::write( - &spdx_bad, - render_spdx("artifact.bin", &zeros, "dx", "rules_dx"), - ) - .expect("spdx bad"); - let err = sbom_verify_files(&artifact, &spdx_bad, &prov_ok, PROVENANCE_BUILDER_RELEASE) - .expect_err("spdx digest"); - assert!( - err.to_string().contains("SPDX missing artifact digest"), - "got {err}" - ); - let prov_text = std::fs::read_to_string(&prov_ok).expect("prov text"); - for (variant, want) in [ - ( - prov_text.replace( - "https://in-toto.io/Statement/v1", - "https://in-toto.io/Statement/v2", - ), - "provenance missing in-toto Statement v1", - ), - ( - prov_text.replace( - "https://slsa.dev/provenance/v1", - "https://slsa.dev/provenance/v2", - ), - "provenance missing SLSA v1 predicate", - ), - ( - prov_text.replace(&digest, &zeros), - "provenance missing subject digest", - ), - ] { - let path = scratch.path().join("variant.prov.json"); - std::fs::write(&path, variant).expect("variant"); - let err = sbom_verify_files(&artifact, &spdx_ok, &path, PROVENANCE_BUILDER_RELEASE) - .expect_err(want); - assert!(err.to_string().contains(want), "want {want}, got {err}"); - } - } - - #[test] - fn notice_text_map_fails_actionable() { - let scratch = scratch_dir(); - let err = read_texts(&[std::path::PathBuf::from("/")]).expect_err("root has no basename"); - assert!(err.contains("no UTF-8 basename"), "got {err}"); - let empty_words = scratch.path().join("empty.txt"); - std::fs::write(&empty_words, " \n").expect("empty words"); - let err = read_texts(&[empty_words]).expect_err("empty words"); - assert!(err.contains("missing-notice-text"), "got {err}"); - let err = join_notice_entries("# no packages\n", &[]).expect_err("no entries"); - assert!(err.contains("lists no packages"), "got {err}"); - let other_words = scratch.path().join("demo-lib-b.txt"); - std::fs::write(&other_words, "Fixture words for demo-lib-b.\n").expect("words b"); - let err = join_notice_entries( - &format!( - "demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", - "0".repeat(64) - ), - &[other_words], - ) - .expect_err("no matching input"); - assert!(err.contains("no matching input file"), "got {err}"); - assert!(err.contains("whole path-component suffix"), "got {err}"); - let first = scratch.path().join("a").join("LICENSE"); - let second = scratch.path().join("b").join("LICENSE"); - for (path, words) in [(&first, "Words for a.\n"), (&second, "Words for b.\n")] { - std::fs::create_dir_all(path.parent().expect("parent")).expect("license dir"); - std::fs::write(path, words).expect("license words"); - } - let both = [first.clone(), second.clone()]; - assert_eq!(read_texts(&both).expect("same basename reads").len(), 2); - let twice = [first.clone(), first.clone()]; - assert_eq!(read_texts(&twice).expect("same path twice").len(), 1); - let joined = join_notice_entries( - &format!( - "demo-lib-a|cargo|1.0.0|MIT|a/LICENSE|{}\ndemo-lib-b|npm|1.0.0|MIT|b/LICENSE|{}\n", - notice_text_sha256("Words for a.\n"), - notice_text_sha256("Words for b.\n") - ), - &both, - ) - .expect("per-package paths join"); - assert_eq!(joined[0].1, "Words for a.\n"); - assert_eq!(joined[1].1, "Words for b.\n"); - let mut rev = both.clone(); - rev.reverse(); - for inputs in [&both[..], &rev[..]] { - let err = join_notice_entries( - &format!( - "demo-lib-a|cargo|1.0.0|MIT|LICENSE|{}\n", - notice_text_sha256("Words for a.\n") - ), - inputs, - ) - .expect_err("bare basename is ambiguous"); - assert!(err.contains("ambiguous-notice-text"), "got {err}"); - let want = format!( - "matches 2 inputs: {}, {}", - first.display(), - second.display() - ); - assert!(err.contains(&want), "want {want}, got {err}"); - assert!(err.contains("lengthen the manifest path"), "got {err}"); - } - } - - #[test] - fn notice_text_suffix_match_is_component_wise() { - let scratch = scratch_dir(); - let inputs: Vec<(std::path::PathBuf, ())> = ["x/a/LICENSE", "x/ba/LICENSE", "y/NOTLICENSE"] - .iter() - .map(|path| (std::path::PathBuf::from(path), ())) - .chain(std::iter::once(( - scratch.path().join("abs").join("COPYING"), - (), - ))) - .collect(); - let found = |want: &str| -> Vec { - notice_text_matches(want, &inputs) - .iter() - .map(|(path, _)| path.display().to_string()) - .collect() - }; - assert_eq!(found("a/LICENSE"), vec!["x/a/LICENSE"]); - assert_eq!(found("x/a/LICENSE"), vec!["x/a/LICENSE"]); - assert_eq!(found("LICENSE"), vec!["x/a/LICENSE", "x/ba/LICENSE"]); - assert!(found("ICENSE").is_empty()); - assert_eq!(found("NOTLICENSE"), vec!["y/NOTLICENSE"]); - assert_eq!( - found("abs/COPYING"), - vec![ - scratch - .path() - .join("abs") - .join("COPYING") - .display() - .to_string() - ] - ); - let mut rev = inputs.clone(); - rev.reverse(); - let rev_found: Vec = notice_text_matches("LICENSE", &rev) - .iter() - .map(|(path, _)| path.display().to_string()) - .collect(); - assert_eq!(rev_found, found("LICENSE")); - } - - #[test] - fn notice_manifest_rejects_unsafe_text_paths() { - for bad in ["/abs", "a/../b", "a//b", "./a", "a\\b", "a/", ".."] { - let err = parse_notice_manifest(&format!( - "# header\ndemo-lib-a|cargo|1.0.0|MIT|{bad}|{}\n", - "0".repeat(64) - )) - .expect_err(bad); - assert!(err.contains("line 2"), "{bad}: got {err}"); - assert!(err.contains(&format!("text '{bad}'")), "{bad}: got {err}"); - } - for good in ["LICENSE", "cargo/serde/LICENSE", "a.b/LICENSE-MIT"] { - parse_notice_manifest(&format!( - "demo-lib-a|cargo|1.0.0|MIT|{good}|{}\n", - "0".repeat(64) - )) - .expect(good); - } - } - - #[test] - fn notice_manifest_rejects_duplicate_identity() { - let sha = "0".repeat(64); - let err = parse_notice_manifest(&format!( - "shared|npm|1.0.0|MIT|npm/LICENSE|{sha}\nother|npm|1.0.0|MIT|o/LICENSE|{sha}\nshared|npm|1.0.0|MIT|x/LICENSE|{sha}\n" - )) - .expect_err("duplicate identity"); - assert!( - err.contains("duplicate entry shared@1.0.0 (MIT) [npm]"), - "got {err}" - ); - let entries = parse_notice_manifest(&format!( - "shared|npm|1.0.0|MIT|npm/shared/LICENSE|{sha}\nshared|cargo|1.0.0|MIT|cargo/shared/LICENSE|{sha}\n" - )) - .expect("same package in two sets"); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].set, "cargo"); - assert_eq!(entries[1].set, "npm"); - } - - #[test] - fn notice_verify_rejects_repeated_header() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - let manifest_text = std::fs::read_to_string(&manifest).expect("read"); - let entries = parse_notice_manifest(&manifest_text).expect("parse"); - let joined = join_notice_entries(&manifest_text, &texts).expect("join"); - let text = render_notice("//demo:root", &joined); - let header = "=== demo-lib-b 2.3.4 (Apache-2.0) [npm] ==="; - assert_eq!(notice_header_count(&text, header), 1); - let repeated = format!("{text}{header}\n{FIXTURE_WORDS_B}"); - assert_eq!(notice_header_count(&repeated, header), 2); - let err = verify_notice_text(&repeated, &entries).expect_err("repeated header"); - assert!( - err.contains(&format!("NOTICE repeats entry header '{header}'")), - "got {err}" - ); - } - - #[test] - fn notice_same_package_two_sets_renders_two_headers() { - let scratch = scratch_dir(); - let cargo = scratch.path().join("cargo").join("shared").join("LICENSE"); - let npm = scratch.path().join("npm").join("shared").join("LICENSE"); - for (path, words) in [ - (&cargo, "Cargo words for shared.\n"), - (&npm, "Npm words for shared.\n"), - ] { - std::fs::create_dir_all(path.parent().expect("parent")).expect("dir"); - std::fs::write(path, words).expect("words"); - } - let manifest_text = format!( - "shared|npm|1.0.0|MIT|npm/shared/LICENSE|{}\nshared|cargo|1.0.0|MIT|cargo/shared/LICENSE|{}\n", - notice_text_sha256("Npm words for shared.\n"), - notice_text_sha256("Cargo words for shared.\n") - ); - let entries = parse_notice_manifest(&manifest_text).expect("parse"); - let joined = - join_notice_entries(&manifest_text, &[npm.clone(), cargo.clone()]).expect("join"); - let text = render_notice("//demo:root", &joined); - assert_eq!(verify_notice_text(&text, &entries), Ok(2)); - let headers: Vec = entries.iter().map(notice_entry_header).collect(); - assert_eq!( - headers, - vec![ - "=== shared 1.0.0 (MIT) [cargo] ===".to_owned(), - "=== shared 1.0.0 (MIT) [npm] ===".to_owned() - ] - ); - assert_eq!( - notice_entry_body(&text, &headers[0], &headers), - Some("Cargo words for shared.\n") - ); - assert_eq!( - notice_entry_body(&text, &headers[1], &headers), - Some("Npm words for shared.\n") - ); - } - - #[test] - fn notice_verify_rejects_drift_after_first_line() { - let scratch = scratch_dir(); - let manifest = scratch.path().join("inventory.txt"); - let drift_words = "First line of the words.\nSecond line of the words.\n"; - std::fs::write( - &manifest, - format!( - "demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", - notice_text_sha256(drift_words) - ), - ) - .expect("manifest"); - let words = scratch.path().join("demo-lib-a.txt"); - std::fs::write(&words, drift_words).expect("words"); - let texts = [words]; - let notice = scratch.path().join("out.NOTICE"); - write_notice(&manifest, ¬ice, "//demo:root", &texts, None).expect("write"); - notice_verify_files(¬ice, &manifest, &texts).expect("verify"); - let text = std::fs::read_to_string(¬ice).expect("read notice"); - let drifted = text.replace("Second line of the words.", "Other second line."); - assert_ne!(drifted, text); - std::fs::write(¬ice, &drifted).expect("drifted"); - let err = notice_verify_files(¬ice, &manifest, &texts).expect_err("drifted line 2"); - assert!( - err.to_string().contains("NOTICE missing words for"), - "got {err}" - ); - std::fs::write(¬ice, format!("{text}Extra appended line.\n")).expect("appended"); - let err = notice_verify_files(¬ice, &manifest, &texts).expect_err("extra line"); - assert!( - err.to_string().contains("NOTICE missing words for"), - "got {err}" - ); - } - - #[test] - fn notice_manifest_requires_six_fields() { - let err = parse_notice_manifest("demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt\n") - .expect_err("five fields"); - assert!(err.contains("want 6"), "got {err}"); - assert!(err.contains("line 1"), "got {err}"); - assert!(err.contains("5-field manifests predate"), "got {err}"); - } - - #[test] - fn notice_manifest_rejects_bad_digest() { - for bad in ["", "abc", &"A".repeat(64), &"g".repeat(64)] { - let err = parse_notice_manifest(&format!("demo-lib-a|cargo|1.0.0|MIT|a.txt|{bad}\n")) - .expect_err("bad digest"); - assert!( - err.contains("line 1") && err.contains("sha256"), - "got {err}" - ); - } - let err = - parse_notice_manifest(&format!("demo-lib-a|cargo|1.0.0|MIT||{}\n", "0".repeat(64))) - .expect_err("digest without text"); - assert!(err.contains("sha256 must be empty"), "got {err}"); - } - - #[test] - fn notice_text_sha256_matches_rendered_body() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - let manifest_text = std::fs::read_to_string(&manifest).expect("read"); - let joined = join_notice_entries(&manifest_text, &texts).expect("join"); - let text = render_notice("//demo:root", &joined); - let headers: Vec = joined.iter().map(|(e, _)| notice_entry_header(e)).collect(); - for (entry, _) in &joined { - let body = - notice_entry_body(&text, ¬ice_entry_header(entry), &headers).expect("body"); - assert_eq!(dx_digest::sha256_hex(body.as_bytes()), entry.text_sha256); - } - assert_eq!(notice_text_sha256("x\n\n\n"), notice_text_sha256("x")); - } - - #[test] - fn notice_gen_rejects_text_digest_drift() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - std::fs::write(&texts[0], "Edited words.\n").expect("drift"); - let out = scratch.path().join("out.NOTICE"); - let err = write_notice(&manifest, &out, "//demo:root", &texts, None).expect_err("drift"); - let err = err.to_string(); - assert!(err.contains("notice-text-digest"), "got {err}"); - assert!(err.contains("demo-lib-a@1.0.0 (MIT)"), "got {err}"); - assert!(err.contains("manifest records"), "got {err}"); - assert!(!out.exists()); - } - - #[test] - fn verify_notice_text_binds_bodies_and_rejects_extras() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - let manifest_text = std::fs::read_to_string(&manifest).expect("read"); - let entries = parse_notice_manifest(&manifest_text).expect("parse"); - let joined = join_notice_entries(&manifest_text, &texts).expect("join"); - let text = render_notice("//demo:root", &joined); - assert_eq!(verify_notice_text(&text, &entries), Ok(2)); - - let headers_only = "NOTICE for //demo:root\n=== demo-lib-a 1.0.0 (MIT) [cargo] ===\n=== demo-lib-b 2.3.4 (Apache-2.0) [npm] ===\n"; - let err = verify_notice_text(headers_only, &entries).expect_err("headers only"); - assert!(err.contains("entry body is empty"), "got {err}"); - - let changed = text.replace( - "Fixture words for demo-lib-a", - "Fixture word for demo-lib-a", - ); - let err = verify_notice_text(&changed, &entries).expect_err("one word"); - assert!( - err.contains("NOTICE missing words for demo-lib-a@1.0.0 (MIT)"), - "got {err}" - ); - assert!(err.contains("manifest records"), "got {err}"); - - let extra = format!("{text}=== other 1.0.0 (MIT) [cargo] ===\nSneaky.\n"); - let err = verify_notice_text(&extra, &entries).expect_err("extra section"); - assert!(err.contains("NOTICE missing words"), "got {err}"); - - let preamble = text.replacen("do not edit.", "do not edit. Extra.", 1); - let err = verify_notice_text(&preamble, &entries).expect_err("preamble"); - assert!(err.contains("outside the audited entries"), "got {err}"); - - let crlf = text.replace('\n', "\r\n"); - assert!(verify_notice_text(&crlf, &entries).is_err()); - - let err = verify_notice_text("plain\n", &entries).expect_err("no header"); - assert!(err.contains("NOTICE for"), "got {err}"); - let err = verify_notice_text(&text, &[]).expect_err("no entries"); - assert!(err.contains("lists no packages"), "got {err}"); - } - - #[test] - fn notice_entry_body_bounds_by_next_header() { - let scratch = scratch_dir(); - let (manifest, texts) = notice_fixture(scratch.path()); - let manifest_text = std::fs::read_to_string(&manifest).expect("read"); - let joined = join_notice_entries(&manifest_text, &texts).expect("join"); - let text = render_notice("//demo:root", &joined); - let headers: Vec = joined - .iter() - .map(|(entry, _)| notice_entry_header(entry)) - .collect(); - for (entry, words) in &joined { - let body = notice_entry_body(&text, ¬ice_entry_header(entry), &headers) - .expect("entry body"); - assert_eq!(body, words.as_str()); - } - assert_eq!( - notice_entry_body(&text, "=== demo-lib-z 9.9.9 (MIT) [cargo] ===", &headers), - None - ); - assert_eq!(notice_entry_body(&text, "demo-lib-a 1.0.0", &headers), None); - } - - #[test] - fn notice_verify_rejects_unbound_bundles() { - let scratch = scratch_dir(); - let notice = scratch.path().join("out.NOTICE"); - std::fs::write(¬ice, "NOTICE for //demo:root\n").expect("notice"); - let manifest = scratch.path().join("inventory.txt"); - std::fs::write(&manifest, "# no packages\n").expect("manifest"); - let err = notice_verify_files(¬ice, &manifest, &[]).expect_err("empty manifest"); - assert!(err.to_string().contains("lists no packages"), "got {err}"); - std::fs::write(&manifest, "demo-lib-a|cargo|1.0.0|MIT||\n").expect("manifest"); - let err = notice_verify_files(¬ice, &manifest, &[]).expect_err("no words"); - assert!( - err.to_string().contains("ships no LICENSE*/NOTICE* words"), - "got {err}" - ); - let bound_words = "Fixture words for demo-lib-a.\n"; - std::fs::write( - &manifest, - format!( - "demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", - notice_text_sha256(bound_words) - ), - ) - .expect("manifest"); - let err = notice_verify_files(¬ice, &manifest, &[]).expect_err("no matching input"); - assert!( - err.to_string().contains("no matching input file"), - "got {err}" - ); - let words = scratch.path().join("demo-lib-a.txt"); - std::fs::write(&words, bound_words).expect("words"); - let err = notice_verify_files(¬ice, &manifest, &[words.clone()]).expect_err("no header"); - assert!( - err.to_string().contains("missing entry header"), - "got {err}" - ); - std::fs::write( - ¬ice, - "NOTICE for //demo:root\n=== demo-lib-a 1.0.0 (MIT) [cargo] ===\nOther words.\n", - ) - .expect("notice header only"); - let err = notice_verify_files(¬ice, &manifest, &[words]).expect_err("no words bound"); - assert!( - err.to_string().contains("NOTICE missing words for"), - "got {err}" - ); - } - - #[cfg(unix)] - fn write_fake_cosign(dir: &Path, body: &str) -> std::path::PathBuf { - std::fs::create_dir_all(dir).expect("bin dir"); - let script = dir.join("cosign"); - dx_testing::executable_script(&script, &body); - script - } - - #[cfg(unix)] - fn fake_cosign(version_line: &str, sign: &str, verify: &str) -> String { - format!( - "#!/bin/sh\ncase \"$1\" in\n version) echo \"{version_line}\"; exit 0;;\n sign-blob) {sign};;\n verify-blob) {verify};;\nesac\nexit 0\n" - ) - } - - #[cfg(unix)] - #[test] - fn signing_live_enforces_pinned_cosign() { - let scratch = scratch_dir(); - let asset = write_artifact(scratch.path(), "artifact.bin", b"artifact bytes\n"); - let assets = [asset.to_string_lossy().into_owned()]; - let good = "GitVersion: v9.8.7"; - let outdir = scratch.path().join("out"); - let scenario = |name: &str, body: &str, sha: Option<&str>| -> Result { - let script = write_fake_cosign(&scratch.path().join(name), body); - let actual = dx_digest::sha256_file_hex(&script).expect("hash fake cosign"); - let path = script.to_string_lossy().into_owned(); - signing_run( - "https://github.com/devhusdk/rules_dx/.github/workflows/release.yml@refs/tags/v0.0.0-dryrun", - SIGNING_ISSUER_DEFAULT, - &assets, - false, - &CosignPin { - path: &path, - version: "v9.8.7", - sha256: sha.unwrap_or(&actual), - }, - &outdir, - ) - }; - let ok_body = fake_cosign(good, "exit 0", "exit 0"); - let err = scenario("wrong-sha", &ok_body, Some(&"0".repeat(64))).expect_err("wrong sha"); - assert!(err.contains("has sha256"), "got {err}"); - assert!(err.contains("refusing to sign"), "got {err}"); - let err = scenario( - "longer-version", - &fake_cosign("GitVersion: v9.8.70", "exit 0", "exit 0"), - None, - ) - .expect_err("v9.8.70"); - assert!(err.contains("GitVersion must be v9.8.7"), "got {err}"); - assert!(err.contains("got: v9.8.70"), "got {err}"); - let err = scenario( - "version-elsewhere", - &fake_cosign("cosign version v9.8.7 (go1.25)", "exit 0", "exit 0"), - None, - ) - .expect_err("no GitVersion line"); - assert!(err.contains("got: none"), "got {err}"); - let err = scenario("sign-fails", &fake_cosign(good, "exit 1", "exit 0"), None) - .expect_err("sign fails"); - assert!(err.contains("failed for"), "got {err}"); - assert!(err.contains("publishing nothing"), "got {err}"); - let err = scenario("verify-fails", &fake_cosign(good, "exit 0", "exit 1"), None) - .expect_err("verify fails"); - assert!(err.contains("bundle rejected"), "got {err}"); - let bundle = outdir.join("artifact.bin.bundle"); - let ok = scenario( - "all-ok", - &fake_cosign(good, "touch \"$5\"; exit 0", "test -f \"$3\""), - None, - ) - .expect("live sign"); - assert!(bundle.is_file(), "missing {}", bundle.display()); - assert!(ok.contains("live sign with cosign v9.8.7"), "got {ok}"); - assert!( - ok.contains(&format!("signed: {} -> {}", assets[0], bundle.display())), - "got {ok}" - ); - assert!(ok.contains("(verified)"), "got {ok}"); - let err = scenario( - "gone-at-sign", - &format!("#!/bin/sh\necho \"{good}\"\n/bin/rm -f \"$0\"\nexit 0\n"), - None, - ) - .expect_err("sign spawn fails"); - assert!(err.contains("cosign sign-blob"), "got {err}"); - assert!(err.starts_with("signing: cannot run '"), "got {err}"); - let err = scenario( - "gone-at-verify", - &fake_cosign(good, "/bin/rm -f \"$0\"; exit 0", "exit 0"), - None, - ) - .expect_err("verify spawn fails"); - assert!(err.contains("cannot run '"), "got {err}"); - assert!(err.contains("cosign verify-blob"), "got {err}"); - scratch.close().expect("cleanup"); - } - - #[cfg(unix)] - #[test] - fn signing_records_track_failure_and_partial() { - let scratch = scratch_dir(); - let first = write_artifact(scratch.path(), "first.bin", b"one\n"); - let second = write_artifact(scratch.path(), "second.bin", b"two\n"); - let assets = [ - first.to_string_lossy().into_owned(), - second.to_string_lossy().into_owned(), - ]; - let good = "GitVersion: v9.8.7"; - let run = |name: &str, verify: &str| -> Vec { - let script = write_fake_cosign( - &scratch.path().join(name), - &fake_cosign(good, "touch \"$5\"; exit 0", verify), - ); - let path = script.to_string_lossy().into_owned(); - let pin = CosignPin { - path: &path, - version: "v9.8.7", - sha256: "", - }; - signing_sign_assets("ID", "ISS", &assets, &pin, &scratch.path().join("out")) - }; - let states = |records: &[OpRecord]| records.iter().map(|r| r.state).collect::>(); - assert_eq!( - states(&run("ok", "exit 0")), - [OpState::Executed, OpState::Executed] - ); - assert_eq!(states(&run("bad-first", "exit 1")), [OpState::Failed]); - let partial = run("bad-second", "case \"$8\" in *second*) exit 1;; esac"); - assert_eq!(states(&partial), [OpState::Executed, OpState::Partial]); - let dry = signing_dry_run_records(&assets); - assert!(dry.iter().all(|r| r.state == OpState::NotAuthorized)); - scratch.close().expect("cleanup"); - } -} +#[path = "release_lib_tests.rs"] +mod tests; diff --git a/deploy/release/src/release_lib_tests.rs b/deploy/release/src/release_lib_tests.rs new file mode 100644 index 0000000000..0c9b1b48a2 --- /dev/null +++ b/deploy/release/src/release_lib_tests.rs @@ -0,0 +1,1650 @@ +use super::*; + +const FORGED_BUILDER_ID: &str = "https://invalid.test/builder"; + +#[test] +fn lock_entries_read_each_ecosystem() { + let npm = r#"{"lockfileVersion":3,"packages":{"":{},"node_modules/a":{"version":"1.0.0"}}}"#; + let entries = lock_entries("npm", npm).expect("npm"); + assert_eq!(entries.len(), 1); + assert_eq!( + (entries[0].package.as_str(), entries[0].version.as_str()), + ("a", "1.0.0") + ); + let go = "module m\n\ngo 1.22\n\nrequire github.com/x/y v1.2.3\n"; + let entries = lock_entries("go", go).expect("go"); + assert_eq!(entries[0].set, "go"); + assert!(lock_entries("cargo", "").is_err()); +} + +#[test] +fn lock_entries_render_as_package_urls() { + let go = "module m\n\ngo 1.22\n\nrequire github.com/x/y v1.2.3\n"; + let entries = lock_entries("go", go).expect("go"); + let text = render_spdx_with_crates("a", "00", "a", "s", &entries); + assert!(text.contains("pkg:golang/github.com/x/y@v1.2.3"), "{text}"); +} + +fn scratch_dir() -> tempfile::TempDir { + tempfile::TempDir::new().expect("scratch") +} + +fn write_artifact(dir: &Path, name: &str, data: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, data).expect("write artifact"); + path +} + +#[test] +fn basename_rejects_missing_name() { + assert!(basename(Path::new("/")).is_err()); + assert_eq!( + basename(Path::new("/tmp/artifact.bin")).expect("base"), + "artifact.bin" + ); +} + +#[test] +fn spdx_bytes_match_python_golden() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_spdx("artifact.bin", digest, "dx", "rules_dx"); + assert!(text.ends_with('\n')); + assert!(!text.ends_with("\r\n")); + let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON"); + assert_eq!(value["spdxVersion"], serde_json::json!("SPDX-2.3")); + assert_eq!(value["dataLicense"], serde_json::json!("CC0-1.0")); + assert_eq!(value["SPDXID"], serde_json::json!("SPDXRef-DOCUMENT")); + assert_eq!(value["name"], serde_json::json!("dx-artifact.bin")); + assert_eq!( + value["documentNamespace"], + serde_json::json!(format!( + "https://github.com/devhusdk/rules_dx/releases/artifact.bin-{digest}" + )) + ); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines[1], " \"SPDXID\": \"SPDXRef-DOCUMENT\","); + assert!(text.contains("\"filesAnalyzed\": false")); + assert!(text.contains(&format!("pkg:generic/dx@{digest}"))); + assert!(text.contains("Organization: rules_dx")); + assert!(!text.contains("verificationCode"), "{text}"); + assert!(!text.contains("packageVerificationCode"), "{text}"); + assert!(!text.contains("null"), "{text}"); + assert_eq!( + value["relationships"], + serde_json::json!([{ + "relatedSpdxElement": "SPDXRef-Package", + "relationshipType": "DESCRIBES", + "spdxElementId": "SPDXRef-DOCUMENT", + }]) + ); +} + +#[test] +fn spdx_lists_inventory_crates() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let manifest = "zed|cargo|2.0.0|MIT||\nalpha|cargo|1.0.0|MIT OR Apache-2.0||\n"; + let crates = parse_notice_manifest(manifest).expect("manifest"); + let text = render_spdx_with_crates("artifact.bin", digest, "dx", "rules_dx", &crates); + let value: serde_json::Value = serde_json::from_str(&text).expect("json"); + let packages = value["packages"].as_array().expect("packages"); + let ids: Vec<&str> = packages + .iter() + .map(|p| p["SPDXID"].as_str().unwrap()) + .collect(); + assert_eq!( + ids, + [ + "SPDXRef-Package", + "SPDXRef-Crate-alpha-1.0.0", + "SPDXRef-Crate-zed-2.0.0" + ] + ); + assert_eq!(packages[1]["licenseDeclared"], "MIT OR Apache-2.0"); + assert_eq!( + packages[2]["externalRefs"][0]["referenceLocator"], + "pkg:cargo/zed@2.0.0" + ); + let rels = value["relationships"].as_array().expect("relationships"); + assert_eq!(rels.len(), 3); + assert_eq!(rels[1]["relationshipType"], "CONTAINS"); + assert_eq!(rels[2]["relatedSpdxElement"], "SPDXRef-Crate-zed-2.0.0"); + assert_eq!(dx_sbom_schema::spdx_errors(&text), Vec::::new()); +} + +#[test] +fn spdx_lists_runtimes_with_file_hashes() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let record = |name: &str, redistribution: &str, files: Vec| { + runtimes::RuntimeRecord { + name: name.to_owned(), + version: "1.2".to_owned(), + redistribution: redistribution.to_owned(), + source: "fixture".to_owned(), + files, + notice: None, + } + }; + let records = [ + record("libc", "os_supplied", Vec::new()), + record( + "libz", + "app_local", + vec![runtimes::RuntimeFile { + name: "libz.so".to_owned(), + sha256: "ab".repeat(32), + }], + ), + ]; + let text = render_spdx_full("artifact.bin", digest, "dx", "rules_dx", &[], &records); + let value: serde_json::Value = serde_json::from_str(&text).expect("json"); + let packages = value["packages"].as_array().expect("packages"); + assert_eq!(packages.len(), 3); + assert_eq!(packages[1]["name"], "libc"); + assert_eq!(packages[1]["supplier"], "Organization: fixture"); + assert!( + packages[1]["comment"] + .as_str() + .unwrap() + .contains("operating system") + ); + assert_eq!(packages[2]["versionInfo"], "1.2"); + let files = value["files"].as_array().expect("files"); + assert_eq!(files.len(), 2); + assert_eq!(files[1]["fileName"], "libz.so"); + assert_eq!(files[1]["checksums"][0]["checksumValue"], "ab".repeat(32)); + let rels = value["relationships"].as_array().expect("relationships"); + assert_eq!(rels.len(), 4); + assert_eq!(rels[3]["spdxElementId"], "SPDXRef-Runtime-libz-1.2"); + assert_eq!(rels[3]["relationshipType"], "CONTAINS"); + assert_eq!(dx_sbom_schema::spdx_errors(&text), Vec::::new()); + assert_eq!( + render_spdx_full("artifact.bin", digest, "dx", "rules_dx", &[], &[]), + render_spdx("artifact.bin", digest, "dx", "rules_dx") + ); +} + +#[test] +fn release_spdx_passes_spdx_2_3_schema() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_spdx("artifact.bin", digest, "dx", "rules_dx"); + assert_eq!(dx_sbom_schema::spdx_errors(&text), Vec::::new()); +} + +#[test] +fn provenance_bytes_match_python_golden() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_provenance("artifact.bin", digest, PROVENANCE_BUILDER_RELEASE); + assert!(text.ends_with('\n')); + let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON"); + assert_eq!( + value["_type"], + serde_json::json!("https://in-toto.io/Statement/v1") + ); + assert_eq!( + value["predicateType"], + serde_json::json!("https://slsa.dev/provenance/v1") + ); + assert_eq!( + value["subject"][0]["digest"]["sha256"], + serde_json::json!(digest) + ); + assert_eq!( + value["subject"][0]["name"], + serde_json::json!("artifact.bin") + ); + assert_eq!( + value["predicate"]["runDetails"]["builder"]["id"], + serde_json::json!(PROVENANCE_BUILDER_RELEASE) + ); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines[1], + " \"_type\": \"https://in-toto.io/Statement/v1\"," + ); +} + +#[test] +fn bcr_bytes_match_python_golden() { + let text = render_bcr_source("rules_dx", "1.2.3"); + assert_eq!( + text, + "{\n \"integrity\": \"\",\n \"strip_prefix\": \"rules_dx-1.2.3\",\n \"url\": \"https://github.com/devhusdk/rules_dx/releases/download/v1.2.3/rules_dx-1.2.3.tar.gz\"\n}\n" + ); +} + +#[test] +fn bcr_family_source_points_at_the_core_archive() { + let text = render_bcr_source("rules_dx_android", "1.2.3"); + assert_eq!( + text, + "{\n \"integrity\": \"\",\n \"strip_prefix\": \"rules_dx-1.2.3/families/android\",\n \"url\": \"https://github.com/devhusdk/rules_dx/releases/download/v1.2.3/rules_dx-1.2.3.tar.gz\"\n}\n" + ); +} + +#[test] +fn render_escapes_non_ascii_like_python() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_spdx("artifact.bin", digest, "dx", "caf\u{e9}"); + assert!(text.contains("caf\\u00e9")); + assert!(!text.contains("caf\u{e9}")); + let astral = render_bcr_source("rules_dx\u{1F600}", "1.2.3"); + assert!(astral.contains("\\ud83d\\ude00")); + assert_eq!(dx_fingerprint::ensure_ascii("caf\u{e9}"), "caf\\u00e9"); + let value = serde_json::json!({"name": "caf\u{e9}"}); + let central = dx_fingerprint::to_json_ascii_pretty(&value).expect("central pretty"); + assert!(central.contains("caf\\u00e9")); + assert_eq!(render_pretty(&value), central); +} + +#[test] +fn write_helpers_round_trip_digests() { + let scratch = scratch_dir(); + let src = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let digest = dx_digest::sha256_file_hex(&src).expect("digest"); + let spdx_out = scratch.path().join("out.spdx.json"); + write_spdx(&src, &spdx_out, "dx", "rules_dx").expect("write spdx"); + let spdx_text = std::fs::read_to_string(&spdx_out).expect("read spdx"); + assert_eq!( + spdx_text, + render_spdx("artifact.bin", &digest, "dx", "rules_dx") + ); + let prov_out = scratch.path().join("out.prov.json"); + write_provenance(&src, &prov_out, PROVENANCE_BUILDER_RELEASE).expect("write prov"); + let prov_text = std::fs::read_to_string(&prov_out).expect("read prov"); + assert_eq!( + prov_text, + render_provenance("artifact.bin", &digest, PROVENANCE_BUILDER_RELEASE) + ); + let bcr_out = scratch.path().join("out.source.json"); + write_bcr_source(&bcr_out, "rules_dx", "1.2.3").expect("write bcr"); + assert_eq!( + std::fs::read_to_string(&bcr_out).expect("read bcr"), + render_bcr_source("rules_dx", "1.2.3") + ); + assert!(write_spdx(&scratch.path().join("missing"), &spdx_out, "dx", "s").is_err()); + assert!(write_provenance(&scratch.path().join("missing"), &prov_out, "b").is_err()); +} + +#[test] +fn bcr_dry_run_matches_shell() { + let out = render_bcr_dry_run("rules_dx", "0.0.0", &["/tmp/a.spdx.json".to_owned()]); + assert!(out.contains("bcr: dry run (BCR_DRY_RUN=1); would submit, submitting nothing:")); + assert!(out.contains(" module: rules_dx")); + assert!(out.contains(" version: 0.0.0")); + assert!(out.contains(" input: a.spdx.json (/tmp/a.spdx.json)")); + assert!(out.contains("gh pr create --repo bazelbuild/bazel-central-registry")); + assert!(out.contains("presubmit: bazel test @rules_dx//...")); + let ok = bcr_run("rules_dx", "0.0.0", &[], true, false).expect("dry"); + assert!(ok.contains("would submit, submitting nothing")); + assert!(bcr_run("other", "0.0.0", &[], true, false).is_err()); + assert!(bcr_run("rules_dx", "0.0.0", &[], false, false).is_err()); + let err = bcr_run("rules_dx", "0.0.0", &[], false, true).expect_err("0.0.0"); + assert!(err.contains("unpublishable (shape check only)")); + assert!(bcr_run("rules_dx", "1.2.3", &[], false, false).is_err()); + let live = bcr_run("rules_dx", "1.2.3", &["/x/y.json".to_owned()], false, true).expect("live"); + assert!(live.contains("\"schema\": \"release-ledger/v1\"")); + assert!(live.contains("\"subject\": \"rules_dx@1.2.3\"")); + assert!(live.contains("\"state\": \"validated\"")); + assert!(live.contains("never pushes itself")); +} + +#[test] +fn bcr_run_accepts_family_modules_and_rejects_other_names() { + let ok = bcr_run("rules_dx_android", "0.0.0", &[], true, false).expect("family dry run"); + assert!(ok.contains(" module: rules_dx_android\n")); + for module in ["rules_dxx", "rules_dx_", "rules_dx-android", "other"] { + let err = bcr_run(module, "0.0.0", &[], true, false).expect_err(module); + assert!( + err.contains("want 'rules_dx' or 'rules_dx_'"), + "{module}: {err}" + ); + } +} + +#[test] +fn bcr_submit_record_needs_success_and_a_bcr_pull_request() { + let url = "https://github.com/bazelbuild/bazel-central-registry/pull/123"; + let ok = bcr_submit_record("rules_dx@1.2.3", "success", url); + assert_eq!(ok.state, OpState::Executed); + assert_eq!(ok.detail, url); + for (result, url) in [ + ("failure", url), + ("cancelled", url), + ("success", ""), + ("success", "https://example.com/pull/1"), + ] { + let record = bcr_submit_record("rules_dx@1.2.3", result, url); + assert_eq!(record.state, OpState::Failed, "{result} {url}"); + } + assert!( + bcr_submit_record("s", "failure", "") + .detail + .contains("failure") + ); +} + +#[test] +fn signing_dry_run_matches_shell() { + let assets = ["/tmp/a.bin".to_owned()]; + let pin = CosignPin { + path: "/nonexistent/cosign", + version: "v9.8.7", + sha256: "0", + }; + let outdir = Path::new("/out"); + let out = render_signing_dry_run("IDENT", "ISSUER", &assets, pin.version, outdir); + assert!( + out.contains("signing: dry run (RELEASE_SIGN_DRY_RUN=1); would sign, publishing nothing:") + ); + assert!(out.contains(&format!("trust root: {SIGNING_TRUST_ROOT}"))); + assert!(out.contains("cosign sign-blob --yes --bundle /out/a.bin.bundle /tmp/a.bin")); + assert!(out.contains("gh attestation create /tmp/a.bin --bundle /out/a.bin.bundle")); + assert!(out.contains("cosign verify-blob")); + assert!(out.contains("cosign: v9.8.7 ")); + assert!(out.contains(SIGNING_BUNDLE_MEDIA_TYPE)); + assert!(signing_run("", "ISSUER", &assets, true, &pin, outdir).is_err()); + assert!(signing_run("ID", "ISS", &[], true, &pin, outdir).is_err()); + let err = signing_run("ID", "ISS", &assets, false, &pin, outdir).expect_err("missing cosign"); + assert!( + err.contains("cannot hash cosign at /nonexistent/cosign"), + "got {err}" + ); + let ok = signing_run("ID", "ISS", &assets, true, &pin, outdir).expect("dry"); + assert!(ok.contains("would sign, publishing nothing")); +} + +#[test] +fn release_dry_run_matches_shell() { + let out = render_release_dry_run("v0.0.0-dryrun", "0"); + assert!(out.contains("release: dry run (RELEASE_DRY_RUN=1)")); + assert!(out.contains("never creates or pushes tags")); + assert!(out.contains("publishing nothing")); + assert!(out.contains("dx_verify")); + assert!(out.contains("notice_demo")); + assert!(out.contains("release_artifacts")); + assert!(out.contains("subject digest equal to artifact sha256")); + assert!(out.contains("signed alongside the SBOM pair")); + let package = out.find("release_artifacts").expect("packaging step"); + let notice = out.find("notice_demo").expect("notice step"); + let sign = out.find("signing_demo").expect("sign step"); + let draft = out.find("github_draft").expect("draft step"); + assert!(package < notice); + assert!(notice < sign); + assert!(sign < draft); + let ok = release_run("v0.0.0-dryrun", None, "0", true, git_unread).expect("dry"); + assert!(ok.contains("would release, publishing nothing")); + assert!(release_run("v1.2.3", Some(SHA), "0", false, git_unread).is_err()); + assert!(release_run("v0.0.0-dryrun", Some(SHA), "1", false, git_unread).is_err()); + assert_eq!( + release_live_records("v1.2.3", Some(SHA), "1", || git_ok(true, SHA, Some(SHA))), + Ok(vec![OpRecord::new( + "tag-local", + "v1.2.3", + OpState::Failed, + "release: tree is dirty; release requires a clean tree" + )]) + ); + assert_eq!( + release_live_records("v1.2.3", Some(SHA), "1", || git_ok(false, SHA, None)), + Ok(vec![OpRecord::new( + "tag-local", + "v1.2.3", + OpState::Failed, + "release: local tag v1.2.3 missing; push it beforehand with owner approval (this program never creates tags)" + )]) + ); + let live = release_run("v1.2.3", Some(SHA), "1", false, || { + git_ok(false, SHA, Some(SHA)) + }) + .expect("live"); + assert!(live.contains("release-ledger/v1")); +} + +const SHA: &str = "a948904f2f0f479b8f8197694b30184b0d2ed1c1"; +const OTHER: &str = "b948904f2f0f479b8f8197694b30184b0d2ed1c1"; + +#[test] +fn release_run_needs_a_valid_candidate_before_reading_git() { + let error = release_run("v1.2.3", None, "1", false, git_unread).expect_err("no commit"); + assert!(error.contains("needs the candidate commit"), "{error}"); + let error = release_run("1.2.3", Some(SHA), "1", false, git_unread).expect_err("no v prefix"); + assert!(error.contains("must be v"), "{error}"); + let error = release_run("v1.2", Some(SHA), "1", false, git_unread).expect_err("bad version"); + assert!(error.contains("invalid version '1.2'"), "{error}"); + let error = release_run("v1.2.3", Some("a948904"), "1", false, git_unread).expect_err("short"); + assert!(error.contains("invalid commit"), "{error}"); + let upper = SHA.to_uppercase(); + let error = release_run("v1.2.3", Some(&upper), "1", false, git_unread).expect_err("upper"); + assert!(error.contains("invalid commit"), "{error}"); +} + +#[test] +fn release_run_rejects_a_stale_head() { + assert_eq!( + release_live_records("v1.2.3", Some(SHA), "1", || git_ok(false, OTHER, Some(SHA))), + Ok(vec![OpRecord::new( + "tag-local", + "v1.2.3", + OpState::Failed, + &format!( + "release: HEAD is {OTHER}, candidate is {SHA}; check out the candidate commit" + ) + )]) + ); +} + +#[test] +fn release_run_rejects_a_local_tag_pointing_elsewhere() { + assert_eq!( + release_live_records("v1.2.3", Some(SHA), "1", || git_ok(false, SHA, Some(OTHER))), + Ok(vec![OpRecord::new( + "tag-local", + "v1.2.3", + OpState::Failed, + &format!("release: local tag v1.2.3 points at {OTHER}, candidate is {SHA}") + )]) + ); +} + +fn git_unread() -> Result { + panic!("git state read before the dry-run, approval, or placeholder gate") +} + +#[test] +#[should_panic(expected = "git state read")] +fn release_reads_git_after_the_gates() { + let _ = release_run("v1.2.3", Some(SHA), "1", false, git_unread); +} + +fn git_ok(tree_dirty: bool, head: &str, tag: Option<&str>) -> Result { + Ok(GitState { + tree_dirty, + head: head.to_owned(), + tag_commit: tag.map(str::to_owned), + }) +} + +#[test] +fn release_git_error_blocks_live_release() { + assert_eq!( + release_live_records("v1.2.3", Some(SHA), "1", || Err( + "release: cannot read git state: x".to_owned() + )), + Ok(vec![OpRecord::new( + "tag-local", + "v1.2.3", + OpState::Failed, + "release: cannot read git state: x" + )]) + ); +} + +#[test] +fn release_live_prints_one_ledger() { + let live = release_run("v1.2.3", Some(SHA), "1", false, || { + git_ok(false, SHA, Some(SHA)) + }) + .expect("live"); + let value: serde_json::Value = serde_json::from_str(&live).expect("json"); + assert_eq!(value["schema"], "release-ledger/v1"); + assert_eq!(value["records"][0]["operation"], "tag-local"); + assert_eq!(value["records"][0]["state"], "validated"); + assert!(live.contains(&format!("clean tree, HEAD and local tag at {SHA}"))); + assert!(!live.contains("executed no release step")); +} + +#[test] +fn release_remote_record_validates_or_fails() { + let candidate = candidate::Candidate::new("1.2.3", SHA).expect("candidate"); + let ok = release_remote_record(&candidate, Ok(format!("{SHA}\trefs/tags/v1.2.3\n"))); + assert_eq!(ok.operation, "tag-remote"); + assert_eq!(ok.state, OpState::Validated); + let bad = release_remote_record(&candidate, Ok(format!("{OTHER}\trefs/tags/v1.2.3\n"))); + assert_eq!(bad.state, OpState::Failed); + assert!(bad.detail.contains("points at")); + let err = release_remote_record(&candidate, Err("fatal: offline".to_owned())); + assert_eq!(err.state, OpState::Failed); + assert!(err.detail.contains("fatal: offline")); +} + +#[test] +fn base64_encodes_short_inputs_with_padding() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); +} + +#[test] +fn bcr_integrity_comes_from_the_archive_sha256() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + assert_eq!( + bcr_integrity_of_digest(digest).expect("integrity"), + "sha256-qUiQTy8PR5uPgZdpSzAYSw0u0cHNKh7A+4XSmaGSpEc=" + ); + assert!(bcr_integrity_of_digest("").is_err()); + assert!(bcr_integrity_of_digest("xyz").is_err()); + assert!(bcr_integrity_of_digest(&"0".repeat(63)).is_err()); + assert!(bcr_integrity_of_digest(&"0".repeat(64)).is_ok()); + assert!( + bcr_integrity_of_digest("A948904F2F0F479B8F8197694B30184B0D2ED1C1CD2A1EC0FB85D299A192A447") + .is_err() + ); +} + +#[test] +fn candidate_source_metadata_comes_from_one_sha() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); + assert_eq!( + text, + "{\n \"integrity\": \"sha256-qUiQTy8PR5uPgZdpSzAYSw0u0cHNKh7A+4XSmaGSpEc=\",\n \"strip_prefix\": \"rules_dx-1.2.3-rc1\",\n \"url\": \"https://github.com/devhusdk/rules_dx/releases/download/v1.2.3-rc1/rules_dx-1.2.3-rc1.tar.gz\"\n}\n" + ); + bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", digest).expect("self-validates"); + assert_eq!( + bcr_source_url("rules_dx", "1.2.3"), + "https://github.com/devhusdk/rules_dx/releases/download/v1.2.3/rules_dx-1.2.3.tar.gz" + ); + assert_eq!(bcr_strip_prefix("rules_dx", "1.2.3"), "rules_dx-1.2.3"); +} + +#[test] +fn candidate_metadata_rejects_shape_versions_and_foreign_modules() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + assert!(render_bcr_source_for_digest("other", "1.2.3", digest).is_err()); + assert!(render_bcr_source_for_digest("rules_dx", "0.0.0", digest).is_err()); + for bad in ["", "v1", "1.2", "1.2.3.4", "1..3", ".2.3"] { + assert!( + render_bcr_source_for_digest("rules_dx", bad, digest).is_err(), + "version {bad:?} must fail" + ); + } +} + +#[test] +fn candidate_source_rejects_a_swapped_archive_digest() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); + let zeros = "0".repeat(64); + let error = bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", &zeros).expect_err("swapped"); + assert!(error.contains("integrity mismatch"), "got {error}"); + assert!(error.contains("immutable version artifacts"), "got {error}"); +} + +#[test] +fn candidate_source_rejects_wrong_prefix_and_url() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); + let error = bcr_candidate_error(&text, "rules_dx", "1.2.4", digest).expect_err("wrong version"); + assert!(error.contains("strip_prefix mismatch"), "got {error}"); + let other_module = + render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); + let error = bcr_candidate_error(&other_module, "other_rules", "1.2.3-rc1", digest) + .expect_err("wrong module"); + assert!(error.contains("strip_prefix mismatch"), "got {error}"); + let tampered = text.replace("v1.2.3-rc1/rules_dx-1.2.3-rc1", "v9.9.9/rules_dx-9.9.9"); + let error = + bcr_candidate_error(&tampered, "rules_dx", "1.2.3-rc1", digest).expect_err("tampered"); + assert!(error.contains("url mismatch"), "got {error}"); +} + +#[test] +fn interrupted_candidate_publication_fails_closed() { + let digest = "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"; + let text = render_bcr_source_for_digest("rules_dx", "1.2.3-rc1", digest).expect("candidate"); + let cut = text.len() / 2; + let error = + bcr_candidate_error(&text[..cut], "rules_dx", "1.2.3-rc1", digest).expect_err("truncated"); + assert!(error.contains("not JSON"), "got {error}"); + let error = + bcr_candidate_error("{}", "rules_dx", "1.2.3-rc1", digest).expect_err("empty object"); + assert!(error.contains("integrity mismatch"), "got {error}"); + let error = + bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", "not-hex").expect_err("bad digest"); + assert!(error.contains("invalid sha256 digest"), "got {error}"); +} + +#[test] +fn write_candidate_source_round_trips_the_archive() { + let scratch = scratch_dir(); + let src = write_artifact(scratch.path(), "candidate.tar.gz", b"candidate bytes\n"); + let digest = dx_digest::sha256_file_hex(&src).expect("digest"); + let dst = scratch.path().join("candidate.source.json"); + let integrity = + write_bcr_source_for_archive(&dst, &modules::ReleaseModule::core(), "1.2.3-rc1", &src) + .expect("write"); + assert_eq!( + integrity, + bcr_integrity_of_digest(&digest).expect("integrity") + ); + let text = std::fs::read_to_string(&dst).expect("read"); + bcr_candidate_error(&text, "rules_dx", "1.2.3-rc1", &digest).expect("validates"); + assert!( + write_bcr_source_for_archive(&dst, &modules::ReleaseModule::core(), "0.0.0", &src).is_err() + ); + assert!( + write_bcr_source_for_archive( + &dst, + &modules::ReleaseModule::core(), + "1.2.3-rc1", + &scratch.path().join("missing") + ) + .is_err() + ); +} + +#[test] +fn remote_tag_error_fails_closed_without_network_or_tag() { + let candidate = candidate::Candidate::new("1.2.3", SHA).expect("candidate"); + let output = format!("{SHA}\trefs/tags/v1.2.3\n"); + assert_eq!( + release_remote_error(&candidate, Ok(output.clone())).expect("remote"), + SHA + ); + let error = release_remote_error(&candidate, Err("fatal: unable to access".to_owned())) + .expect_err("no network"); + assert!( + error.contains("cannot verify remote tag v1.2.3"), + "got {error}" + ); + assert!(error.contains("fatal: unable to access"), "got {error}"); + assert!(error.contains(RELEASE_REMOTE), "got {error}"); + assert!( + error.contains("never creates or pushes tags"), + "got {error}" + ); + let other = candidate::Candidate::new("9.9.9", SHA).expect("candidate"); + let error = release_remote_error(&other, Ok(output)).expect_err("missing tag"); + assert!(error.contains("remote has no tag v9.9.9"), "got {error}"); + assert!(!error.contains("origin"), "got {error}"); +} + +#[test] +fn remote_tag_error_rejects_a_tag_pointing_elsewhere() { + let candidate = candidate::Candidate::new("1.2.3", SHA).expect("candidate"); + let output = format!("{OTHER}\trefs/tags/v1.2.3\n"); + assert_eq!( + release_remote_error(&candidate, Ok(output)), + Err(format!( + "release: remote tag v1.2.3 points at {OTHER}, candidate is {SHA} (git ls-remote --tags {RELEASE_REMOTE})" + )) + ); + let error = + release_remote_error(&candidate, Ok("garbage\n".to_owned())).expect_err("malformed"); + assert!(error.contains("malformed"), "got {error}"); +} + +#[test] +fn release_identity_comes_from_one_repository() { + assert_eq!(RELEASE_REPOSITORY, "https://github.com/devhusdk/rules_dx"); + assert_eq!(RELEASE_REMOTE, "https://github.com/devhusdk/rules_dx.git"); + assert_eq!( + PROVENANCE_BUILDER_RELEASE, + "https://github.com/devhusdk/rules_dx/.github/workflows/release.yml" + ); +} + +#[test] +fn sbom_verify_binds_digest() { + let scratch = scratch_dir(); + let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); + let spdx = scratch.path().join("artifact.spdx.json"); + write_spdx(&artifact, &spdx, "dx", "rules_dx").expect("spdx"); + let prov = scratch.path().join("artifact.prov.json"); + write_provenance(&artifact, &prov, PROVENANCE_BUILDER_RELEASE).expect("prov"); + let ok = + sbom_verify_files(&artifact, &spdx, &prov, PROVENANCE_BUILDER_RELEASE).expect("verify"); + assert!(ok.contains("sbom OK: SPDX-2.3 + SLSA v1 bind")); + assert!(ok.contains(&digest)); + std::fs::write(&spdx, b"{\"spdxVersion\": \"SPDX-3.0\"}").expect("rewrite"); + assert!(sbom_verify_files(&artifact, &spdx, &prov, PROVENANCE_BUILDER_RELEASE).is_err()); +} + +#[test] +fn sbom_verify_reads_spdx_fields_not_text() { + let scratch = scratch_dir(); + let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); + let spdx_path = scratch.path().join("artifact.spdx.json"); + write_spdx(&artifact, &spdx_path, "dx", "rules_dx").expect("spdx"); + let prov = scratch.path().join("artifact.prov.json"); + write_provenance(&artifact, &prov, PROVENANCE_BUILDER_RELEASE).expect("prov"); + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&spdx_path).expect("read")).expect("json"); + let compact = serde_json::to_string(&value).expect("compact"); + assert!( + !compact.contains("\"spdxVersion\": \"SPDX-2.3\""), + "compact differs" + ); + let others = scratch.path().join("artifact.prov.json"); + for (label, text, ok) in [ + ("reordered", reordered_spdx(&value), true), + ("compact", compact, true), + ( + "reordered wrong digest", + reordered_spdx(&replaced_digest(&value, &digest, &"0".repeat(64))), + false, + ), + ] { + std::fs::write(&spdx_path, text.as_bytes()).expect("rewrite"); + let result = sbom_verify_files(&artifact, &spdx_path, &others, PROVENANCE_BUILDER_RELEASE); + assert_eq!(result.is_ok(), ok, "{label}: {result:?}"); + } +} + +/// Renders a document with its top-level keys in reverse order and compact values. +fn reordered_spdx(value: &serde_json::Value) -> String { + let entries: Vec = value + .as_object() + .expect("object") + .iter() + .rev() + .map(|(key, field)| { + format!( + " {key:?}: {}", + serde_json::to_string(field).expect("field") + ) + }) + .collect(); + format!("{{\n{}\n}}", entries.join(",\n")) +} + +#[test] +fn sbom_verify_reads_provenance_fields_not_text() { + let scratch = scratch_dir(); + let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); + let spdx = scratch.path().join("artifact.spdx.json"); + write_spdx(&artifact, &spdx, "dx", "rules_dx").expect("spdx"); + let prov = scratch.path().join("artifact.prov.json"); + let base = render_provenance("artifact.bin", &digest, PROVENANCE_BUILDER_RELEASE); + let value: serde_json::Value = serde_json::from_str(&base).expect("json"); + let with = |edit: &dyn Fn(&mut serde_json::Value)| { + let mut copy = value.clone(); + edit(&mut copy); + serde_json::to_string(©).expect("compact") + }; + let release = PROVENANCE_BUILDER_RELEASE; + let evil = format!("{release}.evil"); + let duplicate = base.replacen( + "\"_type\"", + "\"_type\": \"https://in-toto.io/Statement/v1\",\n \"_type\"", + 1, + ); + let cases: Vec<(&str, String, &str, Result<(), &str>)> = vec![ + ("compact", with(&|_| {}), PROVENANCE_BUILDER_RELEASE, Ok(())), + ( + "reordered", + reordered_spdx(&value), + PROVENANCE_BUILDER_RELEASE, + Ok(()), + ), + ( + "prefix builder", + with(&|v| v["predicate"]["runDetails"]["builder"]["id"] = evil.clone().into()), + release, + Err("is not an allowlisted"), + ), + ( + "builder only in externalParameters", + with(&|v| { + v["predicate"]["runDetails"]["builder"]["id"] = "https://evil.example".into(); + v["predicate"]["buildDefinition"]["externalParameters"]["b"] = release.into(); + }), + release, + Err("is not an allowlisted"), + ), + ( + "digest only in externalParameters", + with(&|v| { + v["predicate"]["buildDefinition"]["externalParameters"]["d"] = + digest.clone().into(); + v["subject"][0]["digest"]["sha256"] = "0".repeat(64).into(); + }), + PROVENANCE_BUILDER_RELEASE, + Err("missing subject digest"), + ), + ( + "other subject name", + with(&|v| v["subject"][0]["name"] = "other.bin".into()), + PROVENANCE_BUILDER_RELEASE, + Err("missing subject digest"), + ), + ( + "missing _type", + with(&|v| { + v.as_object_mut().expect("object").remove("_type"); + }), + PROVENANCE_BUILDER_RELEASE, + Err("missing in-toto Statement v1"), + ), + ( + "not json", + "not json".to_owned(), + PROVENANCE_BUILDER_RELEASE, + Err("not an in-toto statement"), + ), + ( + "duplicate _type", + duplicate, + PROVENANCE_BUILDER_RELEASE, + Err("not an in-toto statement"), + ), + ]; + for (label, text, builder, want) in cases { + std::fs::write(&prov, text.as_bytes()).expect("write"); + let result = sbom_verify_files(&artifact, &spdx, &prov, builder); + match want { + Ok(()) => assert!(result.is_ok(), "{label}: {result:?}"), + Err(part) => { + let err = result.expect_err(label).to_string(); + assert!(err.contains(part), "{label}: want {part}, got {err}"); + } + } + } +} + +fn replaced_digest( + value: &serde_json::Value, + digest: &str, + replacement: &str, +) -> serde_json::Value { + let text = serde_json::to_string(value).expect("compact"); + serde_json::from_str(&text.replace(digest, replacement)).expect("json") +} + +#[test] +fn provenance_builder_allowlist_rejects_forgeries() { + assert!(provenance_builder_error(PROVENANCE_BUILDER_RELEASE).is_ok()); + assert!(provenance_builder_error(FORGED_BUILDER_ID).is_err()); + assert!(provenance_builder_error("https://example.com/builder").is_err()); + assert!(provenance_builder_error("").is_err()); + let scratch = scratch_dir(); + let src = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let dst = scratch.path().join("out.prov.json"); + assert!(write_provenance(&src, &dst, FORGED_BUILDER_ID).is_err()); + assert!(!dst.exists()); +} + +#[test] +fn sbom_verify_rejects_forged_builder() { + let scratch = scratch_dir(); + let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); + let spdx = scratch.path().join("artifact.spdx.json"); + write_spdx(&artifact, &spdx, "dx", "rules_dx").expect("spdx"); + let prov = scratch.path().join("forged.prov.json"); + let forged = render_provenance("artifact.bin", &digest, FORGED_BUILDER_ID); + std::fs::write(&prov, forged.as_bytes()).expect("write forged"); + assert!(sbom_verify_files(&artifact, &spdx, &prov, PROVENANCE_BUILDER_RELEASE).is_err()); +} + +#[test] +fn signing_argv_pins_bundle_and_version() { + let sign = signing_sign_argv("/pin/cosign", "/tmp/a.bin", "a.bin.bundle"); + assert_eq!( + sign, + vec![ + "/pin/cosign", + "sign-blob", + "--yes", + "--new-bundle-format", + "--bundle", + "a.bin.bundle", + "/tmp/a.bin", + ] + ); + let verify = signing_verify_argv( + "/pin/cosign", + "/tmp/a.bin", + "a.bin.bundle", + "IDENT", + "ISSUER", + ); + assert_eq!( + verify, + vec![ + "/pin/cosign", + "verify-blob", + "--bundle", + "a.bin.bundle", + "--certificate-identity", + "IDENT", + "--certificate-oidc-issuer", + "ISSUER", + "/tmp/a.bin", + ] + ); + let real = + " ______ ______\nGitVersion: v9.8.7\nGitCommit: abc\nGoVersion: go1.25\n"; + assert_eq!(cosign_git_version(real), Some("v9.8.7")); + assert!(signing_version_ok(real, "v9.8.7")); + assert!(!signing_version_ok("GitVersion: v9.8.70\n", "v9.8.7")); + assert!(!signing_version_ok("GitVersion: v9.8.6\n", "v9.8.7")); + assert!(!signing_version_ok( + "cosign version v9.8.7 (go1.25)\n", + "v9.8.7" + )); + assert!(!signing_version_ok( + "GitCommit: v9.8.7\nGitVersion: v9.8.8\n", + "v9.8.7" + )); + assert!(!signing_version_ok("", "v9.8.7")); +} + +const FIXTURE_WORDS_A: &str = "Fixture words for demo-lib-a (MIT placeholder).\n"; +const FIXTURE_WORDS_B: &str = "Fixture words for demo-lib-b (Apache-2.0 placeholder).\n"; + +fn fixture_manifest() -> String { + format!( + "demo-lib-b|npm|2.3.4|Apache-2.0|demo-lib-b.txt|{}\ndemo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", + notice_text_sha256(FIXTURE_WORDS_B), + notice_text_sha256(FIXTURE_WORDS_A) + ) +} + +fn notice_fixture(dir: &Path) -> (std::path::PathBuf, Vec) { + let manifest = dir.join("inventory.txt"); + std::fs::write(&manifest, fixture_manifest()).expect("manifest"); + let a = dir.join("demo-lib-a.txt"); + std::fs::write(&a, FIXTURE_WORDS_A).expect("words a"); + let b = dir.join("demo-lib-b.txt"); + std::fs::write(&b, FIXTURE_WORDS_B).expect("words b"); + (manifest, vec![a, b]) +} + +#[test] +fn notice_manifest_parses_and_sorts() { + let entries = parse_notice_manifest(&format!( + "# invented distributed-tier root\n\n{}", + fixture_manifest() + )) + .expect("parse"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].package, "demo-lib-a"); + assert_eq!(entries[1].package, "demo-lib-b"); + assert!(parse_notice_manifest("only|four|fields|here|x").is_err()); + assert!(parse_notice_manifest(&format!("|npm|1.0.0|MIT|a.txt|{}", "0".repeat(64))).is_err()); +} + +#[test] +fn notice_render_is_deterministic_over_input_order() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + let manifest_text = std::fs::read_to_string(&manifest).expect("read"); + let first = join_notice_entries(&manifest_text, &texts).expect("join"); + let mut rev = texts.clone(); + rev.reverse(); + let second = join_notice_entries(&manifest_text, &rev).expect("join rev"); + assert_eq!( + render_notice("//demo:root", &first), + render_notice("//demo:root", &second) + ); + let text = render_notice("//demo:root", &first); + assert!(text.starts_with("NOTICE for //demo:root\n")); + assert!(text.ends_with('\n')); + assert!(!text.ends_with("\r\n")); + let a = text + .find("=== demo-lib-a 1.0.0 (MIT) [cargo] ===") + .expect("a header"); + let b = text + .find("=== demo-lib-b 2.3.4 (Apache-2.0) [npm] ===") + .expect("b header"); + assert!(a < b); + assert!(text.contains("Fixture words for demo-lib-a")); +} + +#[test] +fn notice_write_rebuilds_byte_identical() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + let first = scratch.path().join("first.NOTICE"); + write_notice(&manifest, &first, "//demo:root", &texts, None).expect("first"); + let second = scratch.path().join("second.NOTICE"); + write_notice(&manifest, &second, "//demo:root", &texts, None).expect("second"); + assert_eq!( + std::fs::read(&first).expect("read first"), + std::fs::read(&second).expect("read second") + ); + let ok = notice_verify_files(&first, &manifest, &texts).expect("verify"); + assert!(ok.contains("notice OK: 2 entries bundled")); +} + +#[test] +fn notice_missing_text_fails_actionable() { + let scratch = scratch_dir(); + let manifest = scratch.path().join("inventory.txt"); + std::fs::write(&manifest, "demo-lib-a|cargo|1.0.0|MIT||\n").expect("manifest"); + let out = scratch.path().join("out.NOTICE"); + let err = write_notice(&manifest, &out, "//demo:root", &[], None).expect_err("missing"); + assert!(err.to_string().contains("missing-notice-text")); + let (good_manifest, texts) = notice_fixture(scratch.path()); + let notice = scratch.path().join("good.NOTICE"); + write_notice(&good_manifest, ¬ice, "//demo:root", &texts, None).expect("good"); + std::fs::write(¬ice, "tampered bytes\n").expect("tamper"); + let err = notice_verify_files(¬ice, &good_manifest, &texts).expect_err("tampered"); + assert!(err.to_string().contains("NOTICE")); +} + +#[test] +fn bin_shims_share_failure_rendering() { + assert_eq!( + bin_usage("release_tool spdx", " "), + 1 + ); + assert_eq!( + bin_cannot_write( + "release_tool notice", + Path::new("/nope/notice.NOTICE"), + "read-only filesystem" + ), + 1 + ); + assert_eq!(bin_error("release: owner approval missing"), 1); +} + +#[test] +fn write_spdx_fails_when_target_unwritable() { + let scratch = scratch_dir(); + let src = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + assert!(write_spdx(&src, scratch.path(), "dx", "rules_dx").is_err()); +} + +#[test] +fn sbom_verify_rejects_broken_bindings() { + let scratch = scratch_dir(); + let artifact = write_artifact(scratch.path(), "artifact.bin", b"hello world\n"); + let digest = dx_digest::sha256_file_hex(&artifact).expect("digest"); + let zeros = "0".repeat(digest.len()); + let spdx_ok = scratch.path().join("ok.spdx.json"); + write_spdx(&artifact, &spdx_ok, "dx", "rules_dx").expect("spdx ok"); + let prov_ok = scratch.path().join("ok.prov.json"); + write_provenance(&artifact, &prov_ok, PROVENANCE_BUILDER_RELEASE).expect("prov ok"); + let spdx_bad = scratch.path().join("bad.spdx.json"); + std::fs::write( + &spdx_bad, + render_spdx("artifact.bin", &zeros, "dx", "rules_dx"), + ) + .expect("spdx bad"); + let err = sbom_verify_files(&artifact, &spdx_bad, &prov_ok, PROVENANCE_BUILDER_RELEASE) + .expect_err("spdx digest"); + assert!( + err.to_string().contains("SPDX missing artifact digest"), + "got {err}" + ); + let prov_text = std::fs::read_to_string(&prov_ok).expect("prov text"); + for (variant, want) in [ + ( + prov_text.replace( + "https://in-toto.io/Statement/v1", + "https://in-toto.io/Statement/v2", + ), + "provenance missing in-toto Statement v1", + ), + ( + prov_text.replace( + "https://slsa.dev/provenance/v1", + "https://slsa.dev/provenance/v2", + ), + "provenance missing SLSA v1 predicate", + ), + ( + prov_text.replace(&digest, &zeros), + "provenance missing subject digest", + ), + ] { + let path = scratch.path().join("variant.prov.json"); + std::fs::write(&path, variant).expect("variant"); + let err = sbom_verify_files(&artifact, &spdx_ok, &path, PROVENANCE_BUILDER_RELEASE) + .expect_err(want); + assert!(err.to_string().contains(want), "want {want}, got {err}"); + } +} + +#[test] +fn notice_text_map_fails_actionable() { + let scratch = scratch_dir(); + let err = read_texts(&[std::path::PathBuf::from("/")]).expect_err("root has no basename"); + assert!(err.contains("no UTF-8 basename"), "got {err}"); + let empty_words = scratch.path().join("empty.txt"); + std::fs::write(&empty_words, " \n").expect("empty words"); + let err = read_texts(&[empty_words]).expect_err("empty words"); + assert!(err.contains("missing-notice-text"), "got {err}"); + let err = join_notice_entries("# no packages\n", &[]).expect_err("no entries"); + assert!(err.contains("lists no packages"), "got {err}"); + let other_words = scratch.path().join("demo-lib-b.txt"); + std::fs::write(&other_words, "Fixture words for demo-lib-b.\n").expect("words b"); + let err = join_notice_entries( + &format!( + "demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", + "0".repeat(64) + ), + &[other_words], + ) + .expect_err("no matching input"); + assert!(err.contains("no matching input file"), "got {err}"); + assert!(err.contains("whole path-component suffix"), "got {err}"); + let first = scratch.path().join("a").join("LICENSE"); + let second = scratch.path().join("b").join("LICENSE"); + for (path, words) in [(&first, "Words for a.\n"), (&second, "Words for b.\n")] { + std::fs::create_dir_all(path.parent().expect("parent")).expect("license dir"); + std::fs::write(path, words).expect("license words"); + } + let both = [first.clone(), second.clone()]; + assert_eq!(read_texts(&both).expect("same basename reads").len(), 2); + let twice = [first.clone(), first.clone()]; + assert_eq!(read_texts(&twice).expect("same path twice").len(), 1); + let joined = join_notice_entries( + &format!( + "demo-lib-a|cargo|1.0.0|MIT|a/LICENSE|{}\ndemo-lib-b|npm|1.0.0|MIT|b/LICENSE|{}\n", + notice_text_sha256("Words for a.\n"), + notice_text_sha256("Words for b.\n") + ), + &both, + ) + .expect("per-package paths join"); + assert_eq!(joined[0].1, "Words for a.\n"); + assert_eq!(joined[1].1, "Words for b.\n"); + let mut rev = both.clone(); + rev.reverse(); + for inputs in [&both[..], &rev[..]] { + let err = join_notice_entries( + &format!( + "demo-lib-a|cargo|1.0.0|MIT|LICENSE|{}\n", + notice_text_sha256("Words for a.\n") + ), + inputs, + ) + .expect_err("bare basename is ambiguous"); + assert!(err.contains("ambiguous-notice-text"), "got {err}"); + let want = format!( + "matches 2 inputs: {}, {}", + first.display(), + second.display() + ); + assert!(err.contains(&want), "want {want}, got {err}"); + assert!(err.contains("lengthen the manifest path"), "got {err}"); + } +} + +#[test] +fn notice_text_suffix_match_is_component_wise() { + let scratch = scratch_dir(); + let inputs: Vec<(std::path::PathBuf, ())> = ["x/a/LICENSE", "x/ba/LICENSE", "y/NOTLICENSE"] + .iter() + .map(|path| (std::path::PathBuf::from(path), ())) + .chain(std::iter::once(( + scratch.path().join("abs").join("COPYING"), + (), + ))) + .collect(); + let found = |want: &str| -> Vec { + notice_text_matches(want, &inputs) + .iter() + .map(|(path, _)| path.display().to_string()) + .collect() + }; + assert_eq!(found("a/LICENSE"), vec!["x/a/LICENSE"]); + assert_eq!(found("x/a/LICENSE"), vec!["x/a/LICENSE"]); + assert_eq!(found("LICENSE"), vec!["x/a/LICENSE", "x/ba/LICENSE"]); + assert!(found("ICENSE").is_empty()); + assert_eq!(found("NOTLICENSE"), vec!["y/NOTLICENSE"]); + assert_eq!( + found("abs/COPYING"), + vec![ + scratch + .path() + .join("abs") + .join("COPYING") + .display() + .to_string() + ] + ); + let mut rev = inputs.clone(); + rev.reverse(); + let rev_found: Vec = notice_text_matches("LICENSE", &rev) + .iter() + .map(|(path, _)| path.display().to_string()) + .collect(); + assert_eq!(rev_found, found("LICENSE")); +} + +#[test] +fn notice_manifest_rejects_unsafe_text_paths() { + for bad in ["/abs", "a/../b", "a//b", "./a", "a\\b", "a/", ".."] { + let err = parse_notice_manifest(&format!( + "# header\ndemo-lib-a|cargo|1.0.0|MIT|{bad}|{}\n", + "0".repeat(64) + )) + .expect_err(bad); + assert!(err.contains("line 2"), "{bad}: got {err}"); + assert!(err.contains(&format!("text '{bad}'")), "{bad}: got {err}"); + } + for good in ["LICENSE", "cargo/serde/LICENSE", "a.b/LICENSE-MIT"] { + parse_notice_manifest(&format!( + "demo-lib-a|cargo|1.0.0|MIT|{good}|{}\n", + "0".repeat(64) + )) + .expect(good); + } +} + +#[test] +fn notice_manifest_rejects_duplicate_identity() { + let sha = "0".repeat(64); + let err = parse_notice_manifest(&format!( + "shared|npm|1.0.0|MIT|npm/LICENSE|{sha}\nother|npm|1.0.0|MIT|o/LICENSE|{sha}\nshared|npm|1.0.0|MIT|x/LICENSE|{sha}\n" + )) + .expect_err("duplicate identity"); + assert!( + err.contains("duplicate entry shared@1.0.0 (MIT) [npm]"), + "got {err}" + ); + let entries = parse_notice_manifest(&format!( + "shared|npm|1.0.0|MIT|npm/shared/LICENSE|{sha}\nshared|cargo|1.0.0|MIT|cargo/shared/LICENSE|{sha}\n" + )) + .expect("same package in two sets"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].set, "cargo"); + assert_eq!(entries[1].set, "npm"); +} + +#[test] +fn notice_verify_rejects_repeated_header() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + let manifest_text = std::fs::read_to_string(&manifest).expect("read"); + let entries = parse_notice_manifest(&manifest_text).expect("parse"); + let joined = join_notice_entries(&manifest_text, &texts).expect("join"); + let text = render_notice("//demo:root", &joined); + let header = "=== demo-lib-b 2.3.4 (Apache-2.0) [npm] ==="; + assert_eq!(notice_header_count(&text, header), 1); + let repeated = format!("{text}{header}\n{FIXTURE_WORDS_B}"); + assert_eq!(notice_header_count(&repeated, header), 2); + let err = verify_notice_text(&repeated, &entries).expect_err("repeated header"); + assert!( + err.contains(&format!("NOTICE repeats entry header '{header}'")), + "got {err}" + ); +} + +#[test] +fn notice_same_package_two_sets_renders_two_headers() { + let scratch = scratch_dir(); + let cargo = scratch.path().join("cargo").join("shared").join("LICENSE"); + let npm = scratch.path().join("npm").join("shared").join("LICENSE"); + for (path, words) in [ + (&cargo, "Cargo words for shared.\n"), + (&npm, "Npm words for shared.\n"), + ] { + std::fs::create_dir_all(path.parent().expect("parent")).expect("dir"); + std::fs::write(path, words).expect("words"); + } + let manifest_text = format!( + "shared|npm|1.0.0|MIT|npm/shared/LICENSE|{}\nshared|cargo|1.0.0|MIT|cargo/shared/LICENSE|{}\n", + notice_text_sha256("Npm words for shared.\n"), + notice_text_sha256("Cargo words for shared.\n") + ); + let entries = parse_notice_manifest(&manifest_text).expect("parse"); + let joined = join_notice_entries(&manifest_text, &[npm.clone(), cargo.clone()]).expect("join"); + let text = render_notice("//demo:root", &joined); + assert_eq!(verify_notice_text(&text, &entries), Ok(2)); + let headers: Vec = entries.iter().map(notice_entry_header).collect(); + assert_eq!( + headers, + vec![ + "=== shared 1.0.0 (MIT) [cargo] ===".to_owned(), + "=== shared 1.0.0 (MIT) [npm] ===".to_owned() + ] + ); + assert_eq!( + notice_entry_body(&text, &headers[0], &headers), + Some("Cargo words for shared.\n") + ); + assert_eq!( + notice_entry_body(&text, &headers[1], &headers), + Some("Npm words for shared.\n") + ); +} + +#[test] +fn notice_verify_rejects_drift_after_first_line() { + let scratch = scratch_dir(); + let manifest = scratch.path().join("inventory.txt"); + let drift_words = "First line of the words.\nSecond line of the words.\n"; + std::fs::write( + &manifest, + format!( + "demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", + notice_text_sha256(drift_words) + ), + ) + .expect("manifest"); + let words = scratch.path().join("demo-lib-a.txt"); + std::fs::write(&words, drift_words).expect("words"); + let texts = [words]; + let notice = scratch.path().join("out.NOTICE"); + write_notice(&manifest, ¬ice, "//demo:root", &texts, None).expect("write"); + notice_verify_files(¬ice, &manifest, &texts).expect("verify"); + let text = std::fs::read_to_string(¬ice).expect("read notice"); + let drifted = text.replace("Second line of the words.", "Other second line."); + assert_ne!(drifted, text); + std::fs::write(¬ice, &drifted).expect("drifted"); + let err = notice_verify_files(¬ice, &manifest, &texts).expect_err("drifted line 2"); + assert!( + err.to_string().contains("NOTICE missing words for"), + "got {err}" + ); + std::fs::write(¬ice, format!("{text}Extra appended line.\n")).expect("appended"); + let err = notice_verify_files(¬ice, &manifest, &texts).expect_err("extra line"); + assert!( + err.to_string().contains("NOTICE missing words for"), + "got {err}" + ); +} + +#[test] +fn notice_manifest_requires_six_fields() { + let err = parse_notice_manifest("demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt\n") + .expect_err("five fields"); + assert!(err.contains("want 6"), "got {err}"); + assert!(err.contains("line 1"), "got {err}"); + assert!(err.contains("5-field manifests predate"), "got {err}"); +} + +#[test] +fn notice_manifest_rejects_bad_digest() { + for bad in ["", "abc", &"A".repeat(64), &"g".repeat(64)] { + let err = parse_notice_manifest(&format!("demo-lib-a|cargo|1.0.0|MIT|a.txt|{bad}\n")) + .expect_err("bad digest"); + assert!( + err.contains("line 1") && err.contains("sha256"), + "got {err}" + ); + } + let err = parse_notice_manifest(&format!("demo-lib-a|cargo|1.0.0|MIT||{}\n", "0".repeat(64))) + .expect_err("digest without text"); + assert!(err.contains("sha256 must be empty"), "got {err}"); +} + +#[test] +fn notice_text_sha256_matches_rendered_body() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + let manifest_text = std::fs::read_to_string(&manifest).expect("read"); + let joined = join_notice_entries(&manifest_text, &texts).expect("join"); + let text = render_notice("//demo:root", &joined); + let headers: Vec = joined.iter().map(|(e, _)| notice_entry_header(e)).collect(); + for (entry, _) in &joined { + let body = notice_entry_body(&text, ¬ice_entry_header(entry), &headers).expect("body"); + assert_eq!(dx_digest::sha256_hex(body.as_bytes()), entry.text_sha256); + } + assert_eq!(notice_text_sha256("x\n\n\n"), notice_text_sha256("x")); +} + +#[test] +fn notice_gen_rejects_text_digest_drift() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + std::fs::write(&texts[0], "Edited words.\n").expect("drift"); + let out = scratch.path().join("out.NOTICE"); + let err = write_notice(&manifest, &out, "//demo:root", &texts, None).expect_err("drift"); + let err = err.to_string(); + assert!(err.contains("notice-text-digest"), "got {err}"); + assert!(err.contains("demo-lib-a@1.0.0 (MIT)"), "got {err}"); + assert!(err.contains("manifest records"), "got {err}"); + assert!(!out.exists()); +} + +#[test] +fn verify_notice_text_binds_bodies_and_rejects_extras() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + let manifest_text = std::fs::read_to_string(&manifest).expect("read"); + let entries = parse_notice_manifest(&manifest_text).expect("parse"); + let joined = join_notice_entries(&manifest_text, &texts).expect("join"); + let text = render_notice("//demo:root", &joined); + assert_eq!(verify_notice_text(&text, &entries), Ok(2)); + + let headers_only = "NOTICE for //demo:root\n=== demo-lib-a 1.0.0 (MIT) [cargo] ===\n=== demo-lib-b 2.3.4 (Apache-2.0) [npm] ===\n"; + let err = verify_notice_text(headers_only, &entries).expect_err("headers only"); + assert!(err.contains("entry body is empty"), "got {err}"); + + let changed = text.replace( + "Fixture words for demo-lib-a", + "Fixture word for demo-lib-a", + ); + let err = verify_notice_text(&changed, &entries).expect_err("one word"); + assert!( + err.contains("NOTICE missing words for demo-lib-a@1.0.0 (MIT)"), + "got {err}" + ); + assert!(err.contains("manifest records"), "got {err}"); + + let extra = format!("{text}=== other 1.0.0 (MIT) [cargo] ===\nSneaky.\n"); + let err = verify_notice_text(&extra, &entries).expect_err("extra section"); + assert!(err.contains("NOTICE missing words"), "got {err}"); + + let preamble = text.replacen("do not edit.", "do not edit. Extra.", 1); + let err = verify_notice_text(&preamble, &entries).expect_err("preamble"); + assert!(err.contains("outside the audited entries"), "got {err}"); + + let crlf = text.replace('\n', "\r\n"); + assert!(verify_notice_text(&crlf, &entries).is_err()); + + let err = verify_notice_text("plain\n", &entries).expect_err("no header"); + assert!(err.contains("NOTICE for"), "got {err}"); + let err = verify_notice_text(&text, &[]).expect_err("no entries"); + assert!(err.contains("lists no packages"), "got {err}"); +} + +#[test] +fn notice_entry_body_bounds_by_next_header() { + let scratch = scratch_dir(); + let (manifest, texts) = notice_fixture(scratch.path()); + let manifest_text = std::fs::read_to_string(&manifest).expect("read"); + let joined = join_notice_entries(&manifest_text, &texts).expect("join"); + let text = render_notice("//demo:root", &joined); + let headers: Vec = joined + .iter() + .map(|(entry, _)| notice_entry_header(entry)) + .collect(); + for (entry, words) in &joined { + let body = + notice_entry_body(&text, ¬ice_entry_header(entry), &headers).expect("entry body"); + assert_eq!(body, words.as_str()); + } + assert_eq!( + notice_entry_body(&text, "=== demo-lib-z 9.9.9 (MIT) [cargo] ===", &headers), + None + ); + assert_eq!(notice_entry_body(&text, "demo-lib-a 1.0.0", &headers), None); +} + +#[test] +fn notice_verify_rejects_unbound_bundles() { + let scratch = scratch_dir(); + let notice = scratch.path().join("out.NOTICE"); + std::fs::write(¬ice, "NOTICE for //demo:root\n").expect("notice"); + let manifest = scratch.path().join("inventory.txt"); + std::fs::write(&manifest, "# no packages\n").expect("manifest"); + let err = notice_verify_files(¬ice, &manifest, &[]).expect_err("empty manifest"); + assert!(err.to_string().contains("lists no packages"), "got {err}"); + std::fs::write(&manifest, "demo-lib-a|cargo|1.0.0|MIT||\n").expect("manifest"); + let err = notice_verify_files(¬ice, &manifest, &[]).expect_err("no words"); + assert!( + err.to_string().contains("ships no LICENSE*/NOTICE* words"), + "got {err}" + ); + let bound_words = "Fixture words for demo-lib-a.\n"; + std::fs::write( + &manifest, + format!( + "demo-lib-a|cargo|1.0.0|MIT|demo-lib-a.txt|{}\n", + notice_text_sha256(bound_words) + ), + ) + .expect("manifest"); + let err = notice_verify_files(¬ice, &manifest, &[]).expect_err("no matching input"); + assert!( + err.to_string().contains("no matching input file"), + "got {err}" + ); + let words = scratch.path().join("demo-lib-a.txt"); + std::fs::write(&words, bound_words).expect("words"); + let err = notice_verify_files(¬ice, &manifest, &[words.clone()]).expect_err("no header"); + assert!( + err.to_string().contains("missing entry header"), + "got {err}" + ); + std::fs::write( + ¬ice, + "NOTICE for //demo:root\n=== demo-lib-a 1.0.0 (MIT) [cargo] ===\nOther words.\n", + ) + .expect("notice header only"); + let err = notice_verify_files(¬ice, &manifest, &[words]).expect_err("no words bound"); + assert!( + err.to_string().contains("NOTICE missing words for"), + "got {err}" + ); +} + +#[cfg(unix)] +fn write_fake_cosign(dir: &Path, body: &str) -> std::path::PathBuf { + std::fs::create_dir_all(dir).expect("bin dir"); + let script = dir.join("cosign"); + dx_testing::executable_script(&script, &body); + script +} + +#[cfg(unix)] +fn fake_cosign(version_line: &str, sign: &str, verify: &str) -> String { + format!( + "#!/bin/sh\ncase \"$1\" in\n version) echo \"{version_line}\"; exit 0;;\n sign-blob) {sign};;\n verify-blob) {verify};;\nesac\nexit 0\n" + ) +} + +#[cfg(unix)] +#[test] +fn signing_live_enforces_pinned_cosign() { + let scratch = scratch_dir(); + let asset = write_artifact(scratch.path(), "artifact.bin", b"artifact bytes\n"); + let assets = [asset.to_string_lossy().into_owned()]; + let good = "GitVersion: v9.8.7"; + let outdir = scratch.path().join("out"); + let scenario = |name: &str, body: &str, sha: Option<&str>| -> Result { + let script = write_fake_cosign(&scratch.path().join(name), body); + let actual = dx_digest::sha256_file_hex(&script).expect("hash fake cosign"); + let path = script.to_string_lossy().into_owned(); + signing_run( + "https://github.com/devhusdk/rules_dx/.github/workflows/release.yml@refs/tags/v0.0.0-dryrun", + SIGNING_ISSUER_DEFAULT, + &assets, + false, + &CosignPin { + path: &path, + version: "v9.8.7", + sha256: sha.unwrap_or(&actual), + }, + &outdir, + ) + }; + let ok_body = fake_cosign(good, "exit 0", "exit 0"); + let err = scenario("wrong-sha", &ok_body, Some(&"0".repeat(64))).expect_err("wrong sha"); + assert!(err.contains("has sha256"), "got {err}"); + assert!(err.contains("refusing to sign"), "got {err}"); + let err = scenario( + "longer-version", + &fake_cosign("GitVersion: v9.8.70", "exit 0", "exit 0"), + None, + ) + .expect_err("v9.8.70"); + assert!(err.contains("GitVersion must be v9.8.7"), "got {err}"); + assert!(err.contains("got: v9.8.70"), "got {err}"); + let err = scenario( + "version-elsewhere", + &fake_cosign("cosign version v9.8.7 (go1.25)", "exit 0", "exit 0"), + None, + ) + .expect_err("no GitVersion line"); + assert!(err.contains("got: none"), "got {err}"); + let err = scenario("sign-fails", &fake_cosign(good, "exit 1", "exit 0"), None) + .expect_err("sign fails"); + assert!(err.contains("failed for"), "got {err}"); + assert!(err.contains("publishing nothing"), "got {err}"); + let err = scenario("verify-fails", &fake_cosign(good, "exit 0", "exit 1"), None) + .expect_err("verify fails"); + assert!(err.contains("bundle rejected"), "got {err}"); + let bundle = outdir.join("artifact.bin.bundle"); + let ok = scenario( + "all-ok", + &fake_cosign(good, "touch \"$5\"; exit 0", "test -f \"$3\""), + None, + ) + .expect("live sign"); + assert!(bundle.is_file(), "missing {}", bundle.display()); + assert!(ok.contains("live sign with cosign v9.8.7"), "got {ok}"); + assert!( + ok.contains(&format!("signed: {} -> {}", assets[0], bundle.display())), + "got {ok}" + ); + assert!(ok.contains("(verified)"), "got {ok}"); + let err = scenario( + "gone-at-sign", + &format!("#!/bin/sh\necho \"{good}\"\n/bin/rm -f \"$0\"\nexit 0\n"), + None, + ) + .expect_err("sign spawn fails"); + assert!(err.contains("cosign sign-blob"), "got {err}"); + assert!(err.starts_with("signing: cannot run '"), "got {err}"); + let err = scenario( + "gone-at-verify", + &fake_cosign(good, "/bin/rm -f \"$0\"; exit 0", "exit 0"), + None, + ) + .expect_err("verify spawn fails"); + assert!(err.contains("cannot run '"), "got {err}"); + assert!(err.contains("cosign verify-blob"), "got {err}"); + scratch.close().expect("cleanup"); +} + +#[cfg(unix)] +#[test] +fn signing_records_track_failure_and_partial() { + let scratch = scratch_dir(); + let first = write_artifact(scratch.path(), "first.bin", b"one\n"); + let second = write_artifact(scratch.path(), "second.bin", b"two\n"); + let assets = [ + first.to_string_lossy().into_owned(), + second.to_string_lossy().into_owned(), + ]; + let good = "GitVersion: v9.8.7"; + let run = |name: &str, verify: &str| -> Vec { + let script = write_fake_cosign( + &scratch.path().join(name), + &fake_cosign(good, "touch \"$5\"; exit 0", verify), + ); + let path = script.to_string_lossy().into_owned(); + let pin = CosignPin { + path: &path, + version: "v9.8.7", + sha256: "", + }; + signing_sign_assets("ID", "ISS", &assets, &pin, &scratch.path().join("out")) + }; + let states = |records: &[OpRecord]| records.iter().map(|r| r.state).collect::>(); + assert_eq!( + states(&run("ok", "exit 0")), + [OpState::Executed, OpState::Executed] + ); + assert_eq!(states(&run("bad-first", "exit 1")), [OpState::Failed]); + let partial = run("bad-second", "case \"$8\" in *second*) exit 1;; esac"); + assert_eq!(states(&partial), [OpState::Executed, OpState::Partial]); + let dry = signing_dry_run_records(&assets); + assert!(dry.iter().all(|r| r.state == OpState::NotAuthorized)); + scratch.close().expect("cleanup"); +} diff --git a/deploy/rules/BUILD.bazel b/deploy/rules/BUILD.bazel index 86254d5cae..884fda4970 100644 --- a/deploy/rules/BUILD.bazel +++ b/deploy/rules/BUILD.bazel @@ -192,6 +192,7 @@ rust_library( "src/nuget.rs", "src/oci.rs", "src/oci_scan.rs", + "src/oci_tests.rs", "src/octopus.rs", "src/pub_dev.rs", "src/pypi.rs", diff --git a/deploy/rules/src/oci.rs b/deploy/rules/src/oci.rs index 80fbd9d042..3fd779bff1 100644 --- a/deploy/rules/src/oci.rs +++ b/deploy/rules/src/oci.rs @@ -1144,1989 +1144,5 @@ pub fn oci_main( } #[cfg(test)] -mod tests { - use super::*; - use crate::test_support::*; - - #[test] - fn oci_layout_builds_index() { - let scratch = scratch_dir(); - let (image, _) = oci_save( - scratch.path(), - "oci_demo.tar", - &[oci_test_layer(false)], - "linux/amd64", - ); - let outdir = scratch.path().join("out"); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - &image, - &outdir, - "registry.example.invalid", - "demo/app", - "1.2.3", - "linux/amd64", - ) - .expect("layout"); - assert!(layout.join("oci-layout").is_file()); - assert!(layout.join("blobs").join("sha256").is_dir()); - assert!(!outdir.join("app-oci-layout.tmp").exists()); - let index = std::fs::read_to_string(layout.join("index.json")).expect("index"); - assert!(!index.contains('\n'), "index is compact"); - let parsed: serde_json::Value = serde_json::from_str(&index).expect("index parses"); - assert_eq!(parsed["schemaVersion"], 2); - assert_eq!( - parsed["mediaType"], - "application/vnd.oci.image.index.v1+json" - ); - assert_eq!( - parsed["manifests"][0]["annotations"]["org.opencontainers.image.ref.name"], - "registry.example.invalid/demo/app:1.2.3" - ); - let digest = parsed["manifests"][0]["digest"].as_str().expect("digest"); - let manifest = layout.join("blobs").join("sha256").join(&digest[7..]); - let manifest_bytes = std::fs::read(&manifest).expect("manifest"); - assert!(!manifest_bytes.contains(&b'\n'), "manifest is compact"); - assert_eq!(parsed["manifests"][0]["size"], manifest_bytes.len()); - assert_eq!( - digest, - format!("sha256:{}", dx_digest::sha256_hex(&manifest_bytes)) - ); - assert_eq!( - digest, - format!( - "sha256:{}", - dx_digest::sha256_file_hex(&manifest).expect("hex") - ) - ); - let again = oci_build_layout( - &image, - &outdir, - "registry.example.invalid", - "demo/app", - "1.2.3", - "linux/amd64", - ) - .expect("rebuild layout"); - assert_eq!(again, layout); - assert_eq!( - std::fs::read_to_string(layout.join("index.json")).expect("index"), - index - ); - } - - fn oci_tar(path: &Path, files: &[(&str, &[u8])], links: &[(&str, &str)]) { - let mut builder = tar::Builder::new(Vec::new()); - for (name, body) in files { - let header = ustar_header(name, body.len() as u64, 0o644).expect("header"); - builder.append(&header, *body).expect("append"); - } - for (name, target) in links { - let mut header = tar::Header::new_ustar(); - header.set_entry_type(tar::EntryType::Symlink); - header.set_size(0); - header.set_mode(0o777); - header.set_path(name).expect("link path"); - header.set_link_name(target).expect("link target"); - header.set_cksum(); - builder.append(&header, io::empty()).expect("append link"); - } - std::fs::write(path, builder.into_inner().expect("tar")).expect("write archive"); - } - - fn oci_save( - dir: &Path, - name: &str, - layers: &[Vec], - platform: &str, - ) -> (std::path::PathBuf, String) { - let mut paths = Vec::new(); - for (index, bytes) in layers.iter().enumerate() { - let path = dir.join(format!("{name}-layer-{index}")); - std::fs::write(&path, bytes).expect("write layer"); - paths.push(path); - } - let refs: Vec<&Path> = paths.iter().map(|path| path.as_path()).collect(); - let archive = dir.join(name); - let id = archive_image(&refs, platform, &["/app/hello"], &archive).expect("archive image"); - if name.ends_with(".gz") { - let tar = std::fs::read(&archive).expect("read archive"); - std::fs::write(&archive, gzip_compress(&tar).expect("gzip")).expect("write gz"); - } - (archive, id) - } - - fn oci_config(architecture: &str, diff_ids: &[&[u8]]) -> Vec { - let ids: Vec = diff_ids - .iter() - .map(|tar| format!("sha256:{}", dx_digest::sha256_hex(tar))) - .collect(); - serde_json::to_vec(&serde_json::json!({ - "architecture": architecture, - "config": {"Entrypoint": ["/app/hello"], "Env": ["PATH=/usr/bin", "DEMO=1"]}, - "os": "linux", - "rootfs": {"diff_ids": ids, "type": "layers"}, - })) - .expect("config") - } - - #[test] - fn oci_declared_image_id_binds_the_single_config_digest() { - let scratch = scratch_dir(); - let archive = scratch.path().join("image.tar"); - let layer = oci_test_layer(false); - let config = oci_config("amd64", &[&layer]); - oci_tar( - &archive, - &[ - ( - "manifest.json", - b"[{\"Config\":\"abc.json\",\"RepoTags\":[\"other:tag\"],\"Layers\":[\"abc/layer.tar\"]}]", - ), - ("abc.json", &config), - ("abc/layer.tar", &layer), - ], - &[], - ); - assert_eq!( - oci_declared_image_id(&archive, "linux/amd64").expect("id"), - format!("sha256:{}", dx_digest::sha256_hex(&config)) - ); - let (saved, id) = oci_save( - scratch.path(), - "saved.tar", - &[oci_test_layer(false), oci_test_layer(true)], - "linux/amd64", - ); - assert_eq!( - oci_declared_image_id(&saved, "linux/amd64").expect("saved id"), - id - ); - } - - #[test] - fn oci_declared_image_id_rejects_unsupported_archives() { - let scratch = scratch_dir(); - let archive = scratch.path().join("image.tar"); - let layer = oci_test_layer(false); - let config = oci_config("amd64", &[&layer]); - oci_tar(&archive, &[("manifest.json", b"[]")], &[]); - assert!( - oci_declared_image_id(&archive, "linux/amd64") - .expect_err("empty") - .contains("exactly one") - ); - oci_tar( - &archive, - &[ - ( - "manifest.json", - b"[{\"Config\":\"a.json\",\"Layers\":[\"l.tar\"]},{\"Config\":\"a.json\",\"Layers\":[\"l.tar\"]}]", - ), - ("a.json", &config), - ("l.tar", &layer), - ], - &[], - ); - assert!( - oci_declared_image_id(&archive, "linux/amd64") - .expect_err("many") - .contains("2 images") - ); - oci_tar( - &archive, - &[ - ( - "manifest.json", - b"[{\"Config\":\"gone.json\",\"Layers\":[\"l.tar\"]}]", - ), - ("a.json", &config), - ("l.tar", &layer), - ], - &[], - ); - assert!( - oci_declared_image_id(&archive, "linux/amd64") - .expect_err("missing config") - .contains("missing its config") - ); - oci_tar( - &archive, - &[ - ("manifest.json", b"[{\"Config\":\"a.json\"}]"), - ("a.json", &config), - ], - &[], - ); - assert!( - oci_declared_image_id(&archive, "linux/amd64") - .expect_err("no layers") - .contains("missing Layers") - ); - oci_tar(&archive, &[("manifest.json", b"{not json")], &[]); - assert!( - oci_declared_image_id(&archive, "linux/amd64") - .expect_err("malformed") - .contains("malformed manifest.json") - ); - std::fs::write(&archive, b"image").expect("rootfs"); - assert!(oci_declared_image_id(&archive, "linux/amd64").is_err()); - let layer = { - let mut builder = tar::Builder::new(Vec::new()); - let mut header = ustar_header("etc/os-release", 2, 0o644).expect("header"); - header.set_cksum(); - builder.append(&header, &b"x\n"[..]).expect("append"); - builder.into_inner().expect("tar") - }; - std::fs::write(&archive, layer).expect("layer"); - assert!( - oci_declared_image_id(&archive, "linux/amd64") - .expect_err("rootfs layer") - .contains("no manifest.json or index.json") - ); - } - - fn oci_digest(bytes: &[u8]) -> String { - format!("sha256:{}", dx_digest::sha256_hex(bytes)) - } - - fn oci_blob(bytes: &[u8]) -> String { - format!("blobs/sha256/{}", dx_digest::sha256_hex(bytes)) - } - - fn oci_descriptor(media_type: &str, body: &[u8]) -> serde_json::Value { - serde_json::json!({ - "digest": oci_digest(body), - "mediaType": media_type, - "size": body.len(), - }) - } - - fn oci_index_body(manifests: &[serde_json::Value]) -> Vec { - serde_json::to_vec(&serde_json::json!({ - "manifests": manifests, - "mediaType": "application/vnd.oci.image.index.v1+json", - "schemaVersion": 2, - })) - .expect("index") - } - - fn oci_manifest_body(config: &[u8], layers: &[serde_json::Value]) -> Vec { - serde_json::to_vec(&serde_json::json!({ - "config": oci_descriptor("application/vnd.oci.image.config.v1+json", config), - "layers": layers, - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "schemaVersion": 2, - })) - .expect("manifest") - } - - fn oci_layer_descriptor(layer: &[u8]) -> serde_json::Value { - let media_type = if layer.starts_with(&GZIP_MAGIC) { - "application/vnd.oci.image.layer.v1.tar+gzip" - } else { - "application/vnd.oci.image.layer.v1.tar" - }; - oci_descriptor(media_type, layer) - } - - struct OciIndexFixture { - config: Vec, - manifest: Vec, - members: Vec<(String, Vec)>, - } - - fn oci_index_fixture(layers: &[Vec], architecture: &str, nest: bool) -> OciIndexFixture { - let uncompressed: Vec> = layers - .iter() - .map(|layer| { - if layer.starts_with(&GZIP_MAGIC) { - let mut tar = Vec::new(); - flate2::read::MultiGzDecoder::new(layer.as_slice()) - .read_to_end(&mut tar) - .expect("gunzip"); - tar - } else { - layer.clone() - } - }) - .collect(); - let diff_ids: Vec<&[u8]> = uncompressed.iter().map(Vec::as_slice).collect(); - let config = oci_config(architecture, &diff_ids); - let descriptors: Vec = layers - .iter() - .map(|layer| oci_layer_descriptor(layer)) - .collect(); - let manifest = oci_manifest_body(&config, &descriptors); - let mut image = oci_descriptor("application/vnd.oci.image.manifest.v1+json", &manifest); - image["platform"] = serde_json::json!({"architecture": architecture, "os": "linux"}); - let mut members = vec![ - ( - "oci-layout".to_owned(), - b"{\"imageLayoutVersion\":\"1.0.0\"}".to_vec(), - ), - (oci_blob(&manifest), manifest.clone()), - (oci_blob(&config), config.clone()), - ]; - for layer in layers { - members.push((oci_blob(layer), layer.clone())); - } - let top = if nest { - let statement = b"{\"_type\":\"https://in-toto.io/Statement/v0.1\"}".to_vec(); - let attestation = oci_manifest_body( - b"{}", - &[oci_descriptor("application/vnd.in-toto+json", &statement)], - ); - let mut skipped = - oci_descriptor("application/vnd.oci.image.manifest.v1+json", &attestation); - skipped["annotations"] = serde_json::json!({ - "vnd.docker.reference.digest": oci_digest(&manifest), - "vnd.docker.reference.type": "attestation-manifest", - }); - let inner = oci_index_body(&[image, skipped]); - members.push((oci_blob(&attestation), attestation)); - members.push((oci_blob(&statement), statement)); - members.push((oci_blob(&inner), inner.clone())); - oci_descriptor("application/vnd.oci.image.index.v1+json", &inner) - } else { - image - }; - members.push(("index.json".to_owned(), oci_index_body(&[top]))); - OciIndexFixture { - config, - manifest, - members, - } - } - - fn oci_index_members( - fixture: &OciIndexFixture, - extra: &[(&str, &[u8])], - links: &[(&str, &str)], - ) -> Vec<(String, Vec)> { - let mut members: Vec<(String, Vec)> = fixture - .members - .iter() - .filter(|(name, _)| { - !extra.iter().any(|(other, _)| other == name) - && !links.iter().any(|(other, _)| other == name) - }) - .cloned() - .collect(); - members.extend( - extra - .iter() - .map(|(name, body)| ((*name).to_owned(), body.to_vec())), - ); - members - } - - fn oci_index_tar( - path: &Path, - layers: &[Vec], - architecture: &str, - nest: bool, - extra: &[(&str, &[u8])], - links: &[(&str, &str)], - ) -> OciIndexFixture { - let fixture = oci_index_fixture(layers, architecture, nest); - let members = oci_index_members(&fixture, extra, links); - let files: Vec<(&str, &[u8])> = members - .iter() - .map(|(name, body)| (name.as_str(), body.as_slice())) - .collect(); - oci_tar(path, &files, links); - if path.to_string_lossy().ends_with(".gz") { - let tar = std::fs::read(path).expect("read archive"); - std::fs::write(path, gzip_compress(&tar).expect("gzip")).expect("write gz"); - } - fixture - } - - fn oci_write_dir(dir: &Path, members: &[(String, Vec)], links: &[(&str, &str)]) { - if dir.exists() { - std::fs::remove_dir_all(dir).expect("clear dir"); - } - std::fs::create_dir_all(dir).expect("dir"); - for (name, body) in members { - let path = dir.join(name); - std::fs::create_dir_all(path.parent().expect("parent")).expect("parents"); - std::fs::write(path, body).expect("write member"); - } - for (name, target) in links { - let path = dir.join(name); - std::fs::create_dir_all(path.parent().expect("parent")).expect("parents"); - #[cfg(unix)] - std::os::unix::fs::symlink(target, path).expect("symlink"); - #[cfg(not(unix))] - std::fs::copy( - dir.join(name).parent().expect("parent").join(target), - dir.join(name), - ) - .expect("copy link target"); - } - } - - fn oci_index_dir( - dir: &Path, - layers: &[Vec], - architecture: &str, - nest: bool, - extra: &[(&str, &[u8])], - links: &[(&str, &str)], - ) -> OciIndexFixture { - let fixture = oci_index_fixture(layers, architecture, nest); - oci_write_dir(dir, &oci_index_members(&fixture, extra, links), links); - fixture - } - - #[test] - fn oci_archive_image_id_reads_oci_layout_directories() { - let scratch = scratch_dir(); - let layers = [oci_test_layer(false), oci_test_layer(true)]; - for (name, nest) in [("flat", false), ("nested", true)] { - let dir = scratch.path().join(name); - let fixture = oci_index_dir(&dir, &layers, "amd64", nest, &[], &[]); - let tar = scratch.path().join(format!("{name}.tar")); - oci_index_tar(&tar, &layers, "amd64", nest, &[], &[]); - assert_eq!( - oci_declared_image_id(&dir, "linux/amd64").expect(name), - oci_digest(&fixture.config), - "{name}" - ); - assert_eq!( - oci_read_archive(&dir, None, None).expect(name), - oci_read_archive(&tar, None, None).expect(name), - "{name}" - ); - } - let dir = scratch.path().join("linked"); - let fixture = oci_index_fixture(&layers, "amd64", true); - let manifest_blob = oci_blob(&fixture.manifest); - let config_blob = oci_blob(&fixture.config); - let layer_blob = oci_blob(&layers[1]); - oci_index_dir( - &dir, - &layers, - "amd64", - true, - &[ - ("store/manifest.json", &fixture.manifest), - ("store/config.json", &fixture.config), - ("store/layer.tar.gz", &layers[1]), - ], - &[ - (&manifest_blob, "../../store/manifest.json"), - (&config_blob, "../../store/config.json"), - (&layer_blob, "../../store/layer.tar.gz"), - ], - ); - assert_eq!( - oci_declared_image_id(&dir, "linux/amd64").expect("linked"), - oci_digest(&fixture.config) - ); - let docker_manifest = serde_json::to_vec(&serde_json::json!([{ - "Config": oci_blob(&fixture.config), - "Layers": layers.iter().map(|layer| oci_blob(layer)).collect::>(), - }])) - .expect("docker manifest"); - let dir = scratch.path().join("docker25"); - oci_index_dir( - &dir, - &layers, - "amd64", - true, - &[ - ("manifest.json", &docker_manifest), - ("index.json", b"{not json"), - ], - &[], - ); - assert_eq!( - oci_declared_image_id(&dir, "linux/amd64").expect("docker25"), - oci_digest(&fixture.config) - ); - } - - #[test] - fn oci_archive_image_id_picks_the_deploy_platform_from_an_index() { - let scratch = scratch_dir(); - let amd = oci_index_fixture( - &[oci_test_layer(false), oci_test_layer(true)], - "amd64", - false, - ); - let arm = oci_index_fixture(&[oci_test_layer(false)], "arm64", false); - let mut by_name = std::collections::BTreeMap::new(); - for (name, body) in amd.members.iter().chain(arm.members.iter()) { - if name != "index.json" { - by_name.insert(name.clone(), body.clone()); - } - } - let image = |manifest: &[u8], architecture: &str| { - let mut descriptor = - oci_descriptor("application/vnd.oci.image.manifest.v1+json", manifest); - descriptor["platform"] = - serde_json::json!({"architecture": architecture, "os": "linux"}); - descriptor - }; - let index = oci_index_body(&[ - image(amd.manifest.as_slice(), "amd64"), - image(arm.manifest.as_slice(), "arm64"), - ]); - by_name.insert("index.json".to_owned(), index); - let members: Vec<(String, Vec)> = by_name.into_iter().collect(); - let dir = scratch.path().join("multi"); - oci_write_dir(&dir, &members, &[]); - let tar = scratch.path().join("multi.tar"); - let files: Vec<(&str, &[u8])> = members - .iter() - .map(|(name, body)| (name.as_str(), body.as_slice())) - .collect(); - oci_tar(&tar, &files, &[]); - for path in [&dir, &tar] { - assert_eq!( - oci_declared_image_id(path, "linux/amd64").expect("amd64"), - oci_digest(&amd.config), - "{}", - path.display() - ); - assert_eq!( - oci_declared_image_id(path, "linux/arm64").expect("arm64"), - oci_digest(&arm.config), - "{}", - path.display() - ); - } - assert!(oci_read_archive(&dir, None, None).is_err()); - assert!(oci_read_archive(&tar, None, None).is_err()); - } - - #[test] - fn oci_archive_image_id_rejects_unsupported_oci_layout_directories() { - let scratch = scratch_dir(); - let dir = scratch.path().join("layout"); - let layers = [oci_test_layer(false)]; - let fixture = oci_index_fixture(&layers, "amd64", false); - let image = oci_descriptor( - "application/vnd.oci.image.manifest.v1+json", - &fixture.manifest, - ); - let other = oci_descriptor("application/vnd.oci.image.manifest.v1+json", b"other"); - let two = oci_index_body(&[image.clone(), other]); - let gone = oci_index_body(&[oci_descriptor( - "application/vnd.oci.image.manifest.v1+json", - b"gone", - )]); - let mut deep: Vec<(String, Vec)> = Vec::new(); - let mut top = image.clone(); - for _ in 0..=OCI_INDEX_DEPTH { - let body = oci_index_body(&[top]); - top = oci_descriptor("application/vnd.oci.image.index.v1+json", &body); - deep.push((oci_blob(&body), body)); - } - deep.push(("index.json".to_owned(), oci_index_body(&[top]))); - let deep: Vec<(&str, &[u8])> = deep - .iter() - .map(|(name, body)| (name.as_str(), body.as_slice())) - .collect(); - let manifest_blob = oci_blob(&fixture.manifest); - let layer_blob = oci_blob(&layers[0]); - let docker = |config: &str| { - serde_json::to_vec(&serde_json::json!([{ - "Config": config, - "Layers": [layer_blob.clone()], - }])) - .expect("docker manifest") - }; - let (dotdot, drive, backslash) = (docker("../x"), docker("C:/x"), docker("a\\b")); - let cases: Vec<(&str, Vec<(&str, &[u8])>, &str)> = vec![ - ( - "two images", - vec![("index.json", two.as_slice())], - "2 images", - ), - ( - "missing manifest", - vec![("index.json", gone.as_slice())], - "is missing its blob 'sha256:", - ), - ( - "manifest mismatch", - vec![(manifest_blob.as_str(), &b"{}"[..])], - "does not match its digest", - ), - ("too deep", deep, "deeper than 8 levels"), - ( - "dotdot", - vec![("manifest.json", dotdot.as_slice())], - "path '../x' escapes the archive", - ), - ( - "drive", - vec![("manifest.json", drive.as_slice())], - "missing its config blob 'C:/x'", - ), - ( - "backslash", - vec![("manifest.json", backslash.as_slice())], - "missing its config blob 'a\\b'", - ), - ]; - for (case, extra, want) in cases { - oci_index_dir(&dir, &layers, "amd64", false, &extra, &[]); - let error = oci_declared_image_id(&dir, "linux/amd64").expect_err(case); - assert!(error.contains(want), "{case}: {error}"); - } - std::fs::remove_dir_all(&dir).expect("clear"); - std::fs::create_dir_all(&dir).expect("empty dir"); - assert_eq!( - oci_declared_image_id(&dir, "linux/amd64").expect_err("empty"), - "image directory has no index.json or manifest.json; want an OCI image layout directory" - ); - let bare = scratch.path().join("layout.bin"); - std::fs::write(&bare, b"image").expect("bare"); - assert_eq!( - oci_declared_image_id(&bare, "linux/amd64").expect_err("suffix"), - format!( - "oci image: want a .tar or .tar.gz archive or an OCI layout directory, got '{}'", - bare.display() - ) - ); - } - - #[test] - fn oci_layout_reads_oci_layout_directories() { - let scratch = scratch_dir(); - let layers = [oci_test_layer(false), oci_test_layer(true)]; - let tar = scratch.path().join("index.tar"); - oci_index_tar(&tar, &layers, "amd64", true, &[], &[]); - let dir = scratch.path().join("index"); - oci_index_dir(&dir, &layers, "amd64", true, &[], &[]); - let mut built = Vec::new(); - for (name, image) in [("tar", &tar), ("dir", &dir)] { - let outdir = scratch.path().join(format!("out-{name}")); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect(name); - built.push(oci_layout_json(&layout)); - } - assert_eq!(built[0], built[1]); - let arm = scratch.path().join("arm"); - oci_index_dir(&arm, &layers, "arm64", false, &[], &[]); - let outdir = scratch.path().join("out-arm"); - std::fs::create_dir_all(&outdir).expect("outdir"); - let error = oci_build_layout( - &arm, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect_err("arm64 config"); - assert!( - error.to_string().contains("image config is 'linux/arm64'"), - "{error}" - ); - } - - #[test] - fn oci_archive_image_id_reads_oci_layout_archives() { - let scratch = scratch_dir(); - let layers = [oci_test_layer(false), oci_test_layer(true)]; - for (name, nest) in [ - ("flat.tar", false), - ("nested.tar", true), - ("flat.tar.gz", false), - ("nested.tar.gz", true), - ] { - let archive = scratch.path().join(name); - let fixture = oci_index_tar(&archive, &layers, "amd64", nest, &[], &[]); - assert_eq!( - oci_declared_image_id(&archive, "linux/amd64").expect(name), - oci_digest(&fixture.config), - "{name}" - ); - } - let archive = scratch.path().join("linked.tar"); - let fixture = oci_index_fixture(&layers, "amd64", true); - let manifest_blob = oci_blob(&fixture.manifest); - let config_blob = oci_blob(&fixture.config); - let layer_blob = oci_blob(&layers[1]); - oci_index_tar( - &archive, - &layers, - "amd64", - true, - &[ - ("store/manifest.json", &fixture.manifest), - ("store/config.json", &fixture.config), - ("store/layer.tar.gz", &layers[1]), - ], - &[ - (&manifest_blob, "../../store/manifest.json"), - (&config_blob, "../../store/config.json"), - (&layer_blob, "../../store/layer.tar.gz"), - ], - ); - assert_eq!( - oci_declared_image_id(&archive, "linux/amd64").expect("linked"), - oci_digest(&fixture.config) - ); - let fixture = oci_index_fixture(&layers, "amd64", true); - let docker_manifest = serde_json::to_vec(&serde_json::json!([{ - "Config": oci_blob(&fixture.config), - "Layers": layers.iter().map(|layer| oci_blob(layer)).collect::>(), - "RepoTags": ["demo/app:1.0.0"], - }])) - .expect("docker manifest"); - for (name, index) in [ - ("docker25.tar", None), - ("docker25_index_ignored.tar", Some(b"{not json".as_slice())), - ] { - let archive = scratch.path().join(name); - let mut extra: Vec<(&str, &[u8])> = vec![("manifest.json", docker_manifest.as_slice())]; - if let Some(index) = index { - extra.push(("index.json", index)); - } - oci_index_tar(&archive, &layers, "amd64", true, &extra, &[]); - assert_eq!( - oci_declared_image_id(&archive, "linux/amd64").expect(name), - oci_digest(&fixture.config), - "{name}" - ); - } - } - - #[test] - fn oci_archive_image_id_rejects_unsupported_oci_layout_archives() { - let scratch = scratch_dir(); - let archive = scratch.path().join("image.tar"); - let layers = [oci_test_layer(false)]; - let fixture = oci_index_fixture(&layers, "amd64", false); - let image = oci_descriptor( - "application/vnd.oci.image.manifest.v1+json", - &fixture.manifest, - ); - let config_blob = oci_blob(&fixture.config); - let layer_blob = oci_blob(&layers[0]); - let other_config = oci_config("amd64", &[&oci_test_layer(true)]); - let other_layer = oci_test_layer(true); - let missing = oci_descriptor("application/vnd.oci.image.manifest.v1+json", b"gone"); - let with_digest = |digest: &str| { - let mut descriptor = image.clone(); - descriptor["digest"] = serde_json::json!(digest); - oci_index_body(&[descriptor]) - }; - let other = oci_descriptor("application/vnd.oci.image.manifest.v1+json", b"other"); - let two = oci_index_body(&[image.clone(), other]); - let gone = oci_index_body(&[missing]); - let md5 = with_digest("md5:0123456789abcdef0123456789abcdef"); - let escape = with_digest("sha256:../x"); - let upper = with_digest( - &oci_digest(&fixture.manifest) - .to_uppercase() - .replace("SHA256", "sha256"), - ); - let mut foreign = image.clone(); - foreign["mediaType"] = serde_json::json!("application/vnd.example.manifest+json"); - let foreign = oci_index_body(&[foreign]); - let manifest_with = |layers: &[serde_json::Value]| { - let manifest = oci_manifest_body(&fixture.config, layers); - let index = oci_index_body(&[oci_descriptor( - "application/vnd.oci.image.manifest.v1+json", - &manifest, - )]); - (oci_blob(&manifest), manifest, index) - }; - let mut nondistributable = oci_layer_descriptor(&layers[0]); - nondistributable["mediaType"] = - serde_json::json!("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"); - let nondistributable = manifest_with(&[nondistributable]); - let empty = manifest_with(&[]); - let mut deep: Vec<(String, Vec)> = Vec::new(); - let mut top = image.clone(); - for _ in 0..=OCI_INDEX_DEPTH { - let body = oci_index_body(&[top]); - top = oci_descriptor("application/vnd.oci.image.index.v1+json", &body); - deep.push((oci_blob(&body), body)); - } - deep.push(("index.json".to_owned(), oci_index_body(&[top]))); - let deep: Vec<(&str, &[u8])> = deep - .iter() - .map(|(name, body)| (name.as_str(), body.as_slice())) - .collect(); - let manifest_blob = oci_blob(&fixture.manifest); - let cases: Vec<(&str, Vec<(&str, &[u8])>, &str)> = vec![ - ( - "two images", - vec![("index.json", two.as_slice())], - "2 images", - ), - ( - "malformed", - vec![("index.json", &b"{not json"[..])], - "malformed index.json", - ), - ( - "no manifests", - vec![("index.json", &b"{}"[..])], - "malformed index.json", - ), - ( - "missing manifest", - vec![("index.json", gone.as_slice())], - "is missing its blob 'sha256:", - ), - ( - "manifest mismatch", - vec![(manifest_blob.as_str(), &b"{}"[..])], - "does not match its digest", - ), - ( - "config mismatch", - vec![(config_blob.as_str(), other_config.as_slice())], - "does not match its digest", - ), - ( - "layer mismatch", - vec![(layer_blob.as_str(), other_layer.as_slice())], - "does not match its digest", - ), - ( - "md5", - vec![("index.json", md5.as_slice())], - "not a sha256 digest", - ), - ( - "escape", - vec![("index.json", escape.as_slice())], - "not a sha256 digest", - ), - ( - "uppercase", - vec![("index.json", upper.as_slice())], - "not a sha256 digest", - ), - ( - "nondistributable", - vec![ - ("index.json", nondistributable.2.as_slice()), - (nondistributable.0.as_str(), nondistributable.1.as_slice()), - ], - "unsupported layer media type", - ), - ( - "empty layers", - vec![ - ("index.json", empty.2.as_slice()), - (empty.0.as_str(), empty.1.as_slice()), - ], - "malformed image manifest: missing layers", - ), - ("too deep", deep, "deeper than 8 levels"), - ( - "foreign manifest", - vec![("index.json", foreign.as_slice())], - "unsupported manifest media type 'application/vnd.example.manifest+json'", - ), - ]; - for (case, extra, want) in cases { - oci_index_tar(&archive, &layers, "amd64", false, &extra, &[]); - let error = oci_declared_image_id(&archive, "linux/amd64").expect_err(case); - assert!(error.contains(want), "{case}: {error}"); - } - oci_index_tar(&archive, &layers, "amd64", false, &[], &[]); - assert_eq!( - oci_declared_image_id(&archive, "linux/amd64").expect("valid"), - oci_digest(&fixture.config) - ); - // The same manifest under two tags, or twice in a nested index, is one image. - let mut tagged = Vec::new(); - for tag in ["one", "two"] { - let mut descriptor = image.clone(); - descriptor["annotations"] = - serde_json::json!({"org.opencontainers.image.ref.name": tag}); - tagged.push(descriptor); - } - let tagged_index = oci_index_body(&tagged); - let nested_body = oci_index_body(&[image.clone(), image.clone()]); - let nested_blob = oci_blob(&nested_body); - let nested_index = oci_index_body(&[oci_descriptor( - "application/vnd.oci.image.index.v1+json", - &nested_body, - )]); - let positives: Vec<(&str, Vec<(&str, &[u8])>)> = vec![ - ("two tags", vec![("index.json", tagged_index.as_slice())]), - ( - "nested twice", - vec![ - ("index.json", nested_index.as_slice()), - (nested_blob.as_str(), nested_body.as_slice()), - ], - ), - ]; - for (case, extra) in positives { - oci_index_tar(&archive, &layers, "amd64", false, &extra, &[]); - assert_eq!( - oci_declared_image_id(&archive, "linux/amd64").expect(case), - oci_digest(&fixture.config), - "{case}" - ); - } - } - - #[test] - fn oci_layout_reads_oci_layout_archives() { - let scratch = scratch_dir(); - let layers = [oci_test_layer(false), oci_test_layer(true)]; - let index_tar = scratch.path().join("index.tar"); - let fixture = oci_index_tar(&index_tar, &layers, "amd64", true, &[], &[]); - let docker_manifest = serde_json::to_vec(&serde_json::json!([{ - "Config": "config.json", - "Layers": ["0/layer.tar", "1/layer.tar"], - }])) - .expect("docker manifest"); - let docker_tar = scratch.path().join("docker.tar"); - oci_tar( - &docker_tar, - &[ - ("manifest.json", &docker_manifest), - ("config.json", &fixture.config), - ("0/layer.tar", &layers[0]), - ("1/layer.tar", &layers[1]), - ], - &[], - ); - let mut built = Vec::new(); - for (name, image) in [("index", &index_tar), ("docker", &docker_tar)] { - let outdir = scratch.path().join(name); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect(name); - built.push(oci_layout_json(&layout)); - for layer in &layers { - let stored = layout - .join("blobs") - .join("sha256") - .join(dx_digest::sha256_hex(layer)); - assert_eq!(std::fs::read(stored).expect("blob"), *layer, "{name}"); - } - } - let (manifest, config) = &built[0]; - assert_eq!(built[0], built[1]); - assert_eq!( - manifest["config"]["digest"], - oci_digest(&fixture.config).as_str() - ); - let digests: Vec = layers.iter().map(|layer| oci_digest(layer)).collect(); - assert_eq!(manifest["layers"][0]["digest"], digests[0].as_str()); - assert_eq!(manifest["layers"][1]["digest"], digests[1].as_str()); - assert_eq!( - manifest["layers"][0]["mediaType"], - "application/vnd.oci.image.layer.v1.tar" - ); - assert_eq!( - manifest["layers"][1]["mediaType"], - "application/vnd.oci.image.layer.v1.tar+gzip" - ); - assert_eq!(config["architecture"], "amd64"); - let arm = scratch.path().join("arm.tar"); - oci_index_tar(&arm, &layers, "arm64", false, &[], &[]); - let outdir = scratch.path().join("arm"); - std::fs::create_dir_all(&outdir).expect("outdir"); - let error = oci_build_layout( - &arm, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect_err("arm64 config"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert!( - error.to_string().contains("image config is 'linux/arm64'"), - "{error}" - ); - assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); - } - - #[test] - fn oci_layout_rejects_unusable_registry_repository_and_tag() { - let scratch = scratch_dir(); - let outdir = scratch.path().join("out"); - std::fs::create_dir_all(&outdir).expect("outdir"); - let (image, _) = oci_save( - scratch.path(), - "oci_demo.tar", - &[oci_test_layer(false)], - "linux/amd64", - ); - for registry in ["", "https://registry.invalid"] { - let error = oci_build_layout( - &image, - &outdir, - registry, - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect_err("registry host"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{registry}"); - } - for repository in ["", "/demo/app", "demo/app/"] { - let error = oci_build_layout( - &image, - &outdir, - "registry.invalid", - repository, - "1.0.0", - "linux/amd64", - ) - .expect_err("repository path"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{repository}"); - } - let error = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "", - "linux/amd64", - ) - .expect_err("empty tag"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); - } - - fn oci_layout_json(layout: &Path) -> (serde_json::Value, serde_json::Value) { - let blobs = layout.join("blobs").join("sha256"); - let read = |digest: &str| { - let body = std::fs::read_to_string(blobs.join(&digest[7..])).expect("blob"); - serde_json::from_str::(&body).expect("json") - }; - let index = std::fs::read_to_string(layout.join("index.json")).expect("index"); - let index: serde_json::Value = serde_json::from_str(&index).expect("index parses"); - let manifest = read(index["manifests"][0]["digest"].as_str().expect("digest")); - let config = read(manifest["config"]["digest"].as_str().expect("config")); - (manifest, config) - } - - #[test] - fn oci_layout_diff_id_is_the_uncompressed_digest() { - let scratch = scratch_dir(); - let tar = oci_test_layer(false); - let gz = oci_test_layer(true); - let tar_digest = format!("sha256:{}", dx_digest::sha256_hex(&tar)); - let gz_digest = format!("sha256:{}", dx_digest::sha256_hex(&gz)); - assert_ne!(tar_digest, gz_digest); - for (name, bytes, blob_digest) in [ - ("plain.tar", &tar, &tar_digest), - ("gzip.tar", &gz, &gz_digest), - ] { - let (image, _) = oci_save( - scratch.path(), - name, - std::slice::from_ref(bytes), - "linux/amd64", - ); - let outdir = scratch.path().join(name.replace('.', "_")); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect("layout"); - let (manifest, config) = oci_layout_json(&layout); - assert_eq!( - manifest["layers"][0]["digest"], - blob_digest.as_str(), - "{name}" - ); - assert_eq!(manifest["layers"][0]["size"], bytes.len(), "{name}"); - assert_eq!( - config["rootfs"]["diff_ids"][0], - tar_digest.as_str(), - "{name}" - ); - let stored = layout.join("blobs").join("sha256").join(&blob_digest[7..]); - assert_eq!(std::fs::read(stored).expect("blob"), *bytes, "{name}"); - } - } - - #[test] - fn oci_layout_rejects_malformed_gzip_layers() { - let scratch = scratch_dir(); - let gz = oci_test_layer(true); - let truncated = gz[..gz.len() - 12].to_vec(); - let mut corrupt = gz.clone(); - let mid = corrupt.len() / 2; - corrupt[mid] ^= 0xff; - let tar = oci_test_layer(false); - let config = oci_config("amd64", &[&tar]); - let mut cases: Vec<(String, std::path::PathBuf)> = Vec::new(); - for (name, bytes) in [ - ("not_gzip", b"not gzip at all".to_vec()), - ("truncated", truncated), - ("corrupt", corrupt), - ] { - let image = scratch.path().join(format!("{name}.tar")); - oci_tar( - &image, - &[ - ( - "manifest.json", - b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", - ), - ("c.json", &config), - ("l/layer.tar", &bytes), - ], - &[], - ); - cases.push((name.to_owned(), image)); - } - let (valid, _) = oci_save(scratch.path(), "valid.tar", &[tar.clone()], "linux/amd64"); - let outer = gzip_compress(&std::fs::read(&valid).expect("read valid")).expect("gzip"); - let mut flipped = outer.clone(); - let mid = flipped.len() / 2; - flipped[mid] ^= 0xff; - for (name, bytes) in [ - ("outer_truncated", outer[..outer.len() / 2].to_vec()), - ("outer_corrupt", flipped), - ] { - let image = scratch.path().join(format!("{name}.tar.gz")); - std::fs::write(&image, bytes).expect("write image"); - cases.push((name.to_owned(), image)); - } - for (name, image) in cases { - let outdir = scratch.path().join(&name); - std::fs::create_dir_all(&outdir).expect("outdir"); - let error = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect_err(&name); - assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{name}"); - assert!( - oci_declared_image_id(&image, "linux/amd64").is_err(), - "{name}" - ); - assert!( - std::fs::read_dir(&outdir).expect("outdir").next().is_none(), - "{name}" - ); - } - } - - #[test] - fn oci_paths_reject_invalid_zstd() { - let scratch = scratch_dir(); - let mut zstd = ZSTD_MAGIC.to_vec(); - zstd.extend_from_slice(b"not a real zstd frame"); - let tar = oci_test_layer(false); - let config = oci_config("amd64", &[&tar]); - let layer = scratch.path().join("zstd_layer.tar"); - oci_tar( - &layer, - &[ - ( - "manifest.json", - b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", - ), - ("c.json", &config), - ("l/layer.tar", &zstd), - ], - &[], - ); - let (valid, _) = oci_save(scratch.path(), "valid.tar", &[tar.clone()], "linux/amd64"); - let mut outer = ZSTD_MAGIC.to_vec(); - outer.extend_from_slice(&std::fs::read(&valid).expect("read valid")); - let archive = scratch.path().join("zstd_image.tar"); - std::fs::write(&archive, outer).expect("write image"); - for (name, image, want) in [ - ("layer", layer, "is not valid zstd"), - ( - "archive", - archive, - "unsupported image archive compression zstd", - ), - ] { - let outdir = scratch.path().join(name); - std::fs::create_dir_all(&outdir).expect("outdir"); - let error = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect_err(name); - assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{name}"); - let message = error.to_string(); - assert!(message.contains(want), "{name}: {message}"); - assert!( - !message.contains("diff_ids do not match"), - "{name}: {message}" - ); - let id_error = oci_declared_image_id(&image, "linux/amd64").expect_err(name); - assert!(id_error.contains(want), "{name}: {id_error}"); - assert!( - std::fs::read_dir(&outdir).expect("outdir").next().is_none(), - "{name}" - ); - } - } - - fn zstd_frame(data: &[u8]) -> Vec { - ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) - } - - fn skippable_frame(length: u32, body: &[u8]) -> Vec { - let mut frame = vec![0x50, 0x2A, 0x4D, 0x18]; - frame.extend_from_slice(&length.to_le_bytes()); - frame.extend_from_slice(body); - frame - } - - #[test] - fn oci_read_layer_hashes_every_zstd_frame() { - let (a, b) = (b"first frame data".to_vec(), b"second frame data".to_vec()); - let joined = [a.clone(), b.clone()].concat(); - let want = format!("sha256:{}", dx_digest::sha256_hex(&joined)); - let plain = [zstd_frame(&a), zstd_frame(&b)].concat(); - let skipped = [zstd_frame(&a), skippable_frame(4, b"junk"), zstd_frame(&b)].concat(); - for (name, blob) in [("plain", plain), ("skippable", skipped)] { - let layer = oci_read_layer(blob.as_slice(), name, None).expect(name); - assert_eq!(layer.diff_id, want, "{name}"); - } - } - - #[test] - fn oci_read_layer_rejects_bad_zstd_tails() { - let good = zstd_frame(b"frame"); - let mut garbage = good.clone(); - garbage.extend_from_slice(b"trailing garbage"); - let mut short = good; - short.extend_from_slice(&skippable_frame(8, b"abc")); - for (name, blob) in [("garbage", garbage), ("short", short)] { - let error = oci_read_layer(blob.as_slice(), name, None) - .err() - .expect(name); - assert!(error.to_string().contains("is not valid zstd"), "{error}"); - } - } - - #[test] - fn oci_paths_accept_multi_frame_zstd_layer() { - let scratch = scratch_dir(); - let tar = oci_test_layer(false); - let (head, tail) = tar.split_at(tar.len() / 2); - let zstd = [zstd_frame(head), zstd_frame(tail)].concat(); - let config = oci_config("amd64", &[&tar]); - let image = scratch.path().join("multi_frame.tar"); - oci_tar( - &image, - &[ - ( - "manifest.json", - b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", - ), - ("c.json", &config), - ("l/layer.tar", &zstd), - ], - &[], - ); - let outdir = scratch.path().join("out"); - std::fs::create_dir_all(&outdir).expect("outdir"); - oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect("multi-frame layer"); - } - - #[test] - fn archive_image_hashes_every_zstd_frame() { - let scratch = scratch_dir(); - let tar = oci_test_layer(false); - let (head, tail) = tar.split_at(tar.len() / 2); - let layer = scratch.path().join("multi.tar.zst"); - std::fs::write(&layer, [zstd_frame(head), zstd_frame(tail)].concat()).expect("layer"); - let out = scratch.path().join("out.tar"); - archive_image(&[layer.as_path()], "linux/amd64", &[], &out).expect("archive"); - let archive = oci_read_archive(&out, None, None).expect("read"); - let config: serde_json::Value = serde_json::from_slice(&archive.config).expect("config"); - assert_eq!( - config["rootfs"]["diff_ids"][0], - format!("sha256:{}", dx_digest::sha256_hex(&tar)).as_str() - ); - } - - #[test] - fn oci_paths_accept_zstd_layer() { - let scratch = scratch_dir(); - let tar = oci_test_layer(false); - let zstd = ruzstd::encoding::compress_to_vec( - tar.as_slice(), - ruzstd::encoding::CompressionLevel::Fastest, - ); - let config = oci_config("amd64", &[&tar]); - let image = scratch.path().join("zstd_layer.tar"); - oci_tar( - &image, - &[ - ( - "manifest.json", - b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", - ), - ("c.json", &config), - ("l/layer.tar", &zstd), - ], - &[], - ); - let outdir = scratch.path().join("out"); - std::fs::create_dir_all(&outdir).expect("outdir"); - oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect("zstd layer"); - let blob = outdir - .join("blobs/sha256") - .join(dx_digest::sha256_hex(&zstd)); - assert!(blob.exists() || std::fs::read_dir(&outdir).expect("outdir").next().is_some()); - } - - fn elf_header(machine: u16) -> Vec { - let mut header = vec![0u8; 64]; - header[..4].copy_from_slice(b"\x7fELF"); - header[4] = 2; - header[5] = 1; - header[6] = 1; - header[16..18].copy_from_slice(&2u16.to_le_bytes()); - header[18..20].copy_from_slice(&machine.to_le_bytes()); - header - } - - #[test] - fn oci_layout_platform_matches_the_artifact_bytes() { - let scratch = scratch_dir(); - for (platform, machine, architecture) in [ - ("linux/amd64", 0x3eu16, "amd64"), - ("linux/arm64", 0xb7u16, "arm64"), - ] { - let elf = elf_header(machine); - let tar = tar_archive([("app", elf.as_slice(), 0o755)].into_iter()).expect("tar"); - let name = format!("{architecture}.tar"); - let (image, _) = oci_save(scratch.path(), &name, &[tar], platform); - let outdir = scratch.path().join(architecture); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - platform, - ) - .expect("layout"); - let (manifest, config) = oci_layout_json(&layout); - let blob = manifest["layers"][0]["digest"].as_str().expect("digest"); - let stored = std::fs::read(layout.join("blobs").join("sha256").join(&blob[7..])) - .expect("layer blob"); - let mut archive = tar::Archive::new(stored.as_slice()); - let mut entry = archive - .entries() - .expect("entries") - .next() - .expect("entry") - .expect("entry"); - let mut bytes = Vec::new(); - entry.read_to_end(&mut bytes).expect("read app"); - let observed = match u16::from_le_bytes([bytes[18], bytes[19]]) { - 0x3e => "amd64", - 0xb7 => "arm64", - other => panic!("unexpected e_machine {other:#x}"), - }; - assert_eq!(config["architecture"], observed, "{platform}"); - assert_eq!(config["architecture"], architecture, "{platform}"); - assert_eq!(config["os"], "linux", "{platform}"); - } - let (arm, _) = oci_save( - scratch.path(), - "mismatch.tar", - &[oci_test_layer(false)], - "linux/arm64", - ); - let outdir = scratch.path().join("mismatch"); - std::fs::create_dir_all(&outdir).expect("outdir"); - let error = oci_build_layout( - &arm, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect_err("arm64 config staged as amd64"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert!(error.to_string().contains("linux/arm64"), "{error}"); - assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); - } - - #[test] - fn oci_layout_rejects_missing_and_unsupported_platforms() { - let scratch = scratch_dir(); - let (image, _) = oci_save( - scratch.path(), - "oci_demo.tar", - &[oci_test_layer(false)], - "linux/amd64", - ); - let outdir = scratch.path().join("out"); - std::fs::create_dir_all(&outdir).expect("outdir"); - for platform in [ - "", - "windows/amd64", - "linux/riscv64", - "amd64", - "linux/arm64/v8", - ] { - let error = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - platform, - ) - .expect_err("unsupported platform"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{platform}"); - assert!(error.to_string().contains("platform"), "{platform}"); - } - assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); - } - - #[test] - fn oci_layout_media_type_follows_the_layer_bytes() { - let scratch = scratch_dir(); - for name in ["oci_demo.tar", "oci_demo.tar.gz"] { - let (image, _) = oci_save( - scratch.path(), - name, - &[oci_test_layer(false), oci_test_layer(true)], - "linux/amd64", - ); - let outdir = scratch.path().join(name.replace('.', "_")); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect("layout"); - let (manifest, _) = oci_layout_json(&layout); - assert_eq!( - manifest["layers"][0]["mediaType"], "application/vnd.oci.image.layer.v1.tar", - "{name}" - ); - assert_eq!( - manifest["layers"][1]["mediaType"], "application/vnd.oci.image.layer.v1.tar+gzip", - "{name}" - ); - } - } - - #[test] - fn oci_layout_keeps_every_docker_save_layer_and_the_config() { - let scratch = scratch_dir(); - let plain = tar_archive([("etc/demo.conf", b"demo=1\n".as_slice(), 0o644)].into_iter()) - .expect("tar"); - let gz_tar = - tar_archive([("app/hello", b"hello\n".as_slice(), 0o755)].into_iter()).expect("tar"); - let gz = gzip_compress(&gz_tar).expect("gzip"); - let config = oci_config("amd64", &[&plain, &gz_tar]); - let config_hex = dx_digest::sha256_hex(&config); - let plain_hex = dx_digest::sha256_hex(&plain); - let gz_hex = dx_digest::sha256_hex(&gz); - let manifest = format!( - "[{{\"Config\":\"blobs/sha256/{config_hex}\",\"RepoTags\":[\"demo:1\"],\"Layers\":[\"blobs/sha256/{plain_hex}\",\"blobs/sha256/{gz_hex}\"]}}]" - ); - let image = scratch.path().join("image.tar"); - let config_name = format!("blobs/sha256/{config_hex}"); - let plain_name = format!("blobs/sha256/{plain_hex}"); - let gz_name = format!("blobs/sha256/{gz_hex}"); - oci_tar( - &image, - &[ - (gz_name.as_str(), &gz), - ("manifest.json", manifest.as_bytes()), - (config_name.as_str(), &config), - (plain_name.as_str(), &plain), - ], - &[], - ); - let outdir = scratch.path().join("out"); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ) - .expect("layout"); - let blobs = layout.join("blobs").join("sha256"); - let (manifest, parsed_config) = oci_layout_json(&layout); - let config_digest = manifest["config"]["digest"] - .as_str() - .expect("config digest"); - assert_eq!(config_digest, format!("sha256:{config_hex}")); - assert_eq!( - oci_declared_image_id(&image, "linux/amd64").expect("id"), - config_digest - ); - assert_eq!(manifest["config"]["size"], config.len()); - assert_eq!( - std::fs::read(blobs.join(&config_hex)).expect("config blob"), - config - ); - assert_eq!( - parsed_config["config"]["Env"], - serde_json::json!(["PATH=/usr/bin", "DEMO=1"]) - ); - assert_eq!( - parsed_config["config"]["Entrypoint"], - serde_json::json!(["/app/hello"]) - ); - let layers = manifest["layers"].as_array().expect("layers"); - assert_eq!(layers.len(), 2); - for (layer, (hex, bytes)) in layers.iter().zip([(&plain_hex, &plain), (&gz_hex, &gz)]) { - assert_eq!(layer["digest"], format!("sha256:{hex}")); - assert_eq!(layer["size"], bytes.len()); - assert_eq!(std::fs::read(blobs.join(hex)).expect("layer blob"), *bytes); - } - let mut stored: Vec = std::fs::read_dir(&blobs) - .expect("blobs") - .map(|entry| { - entry - .expect("entry") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect(); - stored.sort(); - assert_eq!(stored.len(), 4, "{stored:?}"); - } - - const FAKE_SECRET: &str = "s3cret-token-value"; - - /// Runs one live push of `image` against a fake registry. - fn fake_live_push(fake: &FakeRegistry, image: &Path, outdir: &Path, platform: &str) -> i32 { - let target = registry::RegistryRef { - endpoint: registry::Endpoint::for_test(&fake.origin), - name: "demo/app".to_owned(), - }; - oci_live_push_to( - &crate::http::upload_agent_with_proxy(None), - &target, - image, - outdir, - &OciPush { - registry: "registry.invalid", - repository: "demo/app", - tag: "1.2.3", - platform, - creds: registry::Credentials { - user: "me", - secret: FAKE_SECRET, - }, - }, - ) - } - - #[test] - fn oci_live_push_pushes_the_staged_layout_digest() { - let scratch = scratch_dir(); - let (image, id) = oci_save( - scratch.path(), - "image.tar.gz", - &[oci_test_layer(false), oci_test_layer(true)], - "linux/amd64", - ); - let dry = scratch.path().join("dry"); - std::fs::create_dir_all(&dry).expect("outdir"); - let layout = oci_build_layout( - &image, - &dry, - "registry.invalid", - "demo/app", - "1.2.3", - "linux/amd64", - ) - .expect("layout"); - let (manifest, _) = oci_layout_json(&layout); - assert_eq!(manifest["config"]["digest"].as_str(), Some(id.as_str())); - let index: serde_json::Value = - serde_json::from_slice(&std::fs::read(layout.join("index.json")).expect("index")) - .expect("index json"); - let staged = index["manifests"][0]["digest"] - .as_str() - .expect("digest") - .to_owned(); - let fake = FakeRegistry::start(FakeConfig { - challenge: FakeChallenge::Bearer, - ..FakeConfig::default() - }); - let live = scratch.path().join("live"); - std::fs::create_dir_all(&live).expect("outdir"); - assert_eq!(fake_live_push(&fake, &image, &live, "linux/amd64"), 0); - let requests = fake.requests(); - let manifest_put = requests.last().expect("manifest"); - assert_eq!(manifest_put.path, "/v2/demo/app/manifests/1.2.3"); - assert_eq!(manifest_put.body_digest, staged); - let blobs: Vec<&str> = requests - .iter() - .filter(|r| r.method == "PUT" && r.path.contains("/blobs/uploads/")) - .map(|r| r.body_digest.as_str()) - .collect(); - assert!(blobs.contains(&id.as_str()), "{blobs:?}"); - } - - #[test] - fn oci_live_push_rejects_what_the_layout_rejects() { - let scratch = scratch_dir(); - let layers = [oci_test_layer(false)]; - let (arm, _) = oci_save(scratch.path(), "arm.tar", &layers, "linux/arm64"); - let (tgz, _) = oci_save(scratch.path(), "image.tgz", &layers, "linux/amd64"); - let (amd, _) = oci_save(scratch.path(), "amd.tar", &layers, "linux/amd64"); - let cases = [ - ("arm64 image", &arm, "linux/amd64"), - ("tgz suffix", &tgz, "linux/amd64"), - ("s390x platform", &amd, "linux/s390x"), - ]; - let fake = FakeRegistry::start(FakeConfig::default()); - for (name, image, platform) in cases { - let outdir = scratch.path().join(name.replace(' ', "_")); - std::fs::create_dir_all(&outdir).expect("outdir"); - let layout = oci_build_layout( - image, - &outdir, - "registry.invalid", - "demo/app", - "1.2.3", - platform, - ) - .expect_err(name); - assert_eq!( - layout.kind(), - io::ErrorKind::InvalidInput, - "{name}: {layout}" - ); - assert_eq!( - std::fs::read_dir(&outdir).expect("read outdir").count(), - 0, - "{name}" - ); - let declared = oci_declared_image_id(image, platform).expect_err(name); - if name == "arm64 image" { - assert!(layout.to_string().contains("linux/arm64"), "{layout}"); - assert!(declared.contains("linux/arm64"), "{declared}"); - } - assert_eq!(fake_live_push(&fake, image, &outdir, platform), 1, "{name}"); - assert!(fake.requests().is_empty(), "{name}"); - } - let outdir = scratch.path().join("arm_ok"); - std::fs::create_dir_all(&outdir).expect("outdir"); - assert_eq!(fake_live_push(&fake, &arm, &outdir, "linux/arm64"), 0); - assert!(!fake.requests().is_empty()); - } - - #[test] - fn oci_live_push_stops_before_manifest_on_blob_failure() { - let scratch = scratch_dir(); - let (image, _) = oci_save( - scratch.path(), - "image.tar", - &[oci_test_layer(false)], - "linux/amd64", - ); - let fake = FakeRegistry::start(FakeConfig { - fail: vec![("PUT".to_owned(), "/blobs/uploads/".to_owned(), 500)], - ..FakeConfig::default() - }); - assert_eq!( - fake_live_push(&fake, &image, scratch.path(), "linux/amd64"), - 1 - ); - let requests = fake.requests(); - assert!(requests.iter().any(|r| r.path.contains("/blobs/uploads/"))); - assert!(requests.iter().all(|r| !r.path.contains("/manifests/"))); - } - - #[test] - fn oci_live_push_output_has_no_credentials() { - const CHILD: &str = "DX_OCI_LIVE_PUSH_CHILD"; - if std::env::var_os(CHILD).is_some() { - let scratch = scratch_dir(); - let (image, _) = oci_save( - scratch.path(), - "image.tar", - &[oci_test_layer(false)], - "linux/amd64", - ); - for (challenge, secret) in [ - (FakeChallenge::Bearer, FAKE_SECRET), - (FakeChallenge::Bearer, "wrong"), - (FakeChallenge::Basic, "wrong"), - ] { - let fake = FakeRegistry::start(FakeConfig { - challenge, - secret: secret.to_owned(), - ..FakeConfig::default() - }); - let code = fake_live_push(&fake, &image, scratch.path(), "linux/amd64"); - assert_eq!(code, i32::from(secret != FAKE_SECRET)); - } - return; - } - let run = std::process::Command::new(std::env::current_exe().expect("test binary")) - .args([ - "oci::tests::oci_live_push_output_has_no_credentials", - "--exact", - "--nocapture", - ]) - .env(CHILD, "1") - .output() - .expect("spawn child"); - let text = format!( - "{}{}", - String::from_utf8_lossy(&run.stdout), - String::from_utf8_lossy(&run.stderr) - ); - assert!(run.status.success(), "{text}"); - assert!(text.contains("oci_deploy: pushed registry.invalid/demo/app:1.2.3@sha256:")); - assert!(text.contains(registry::OCI_BAD_CREDENTIALS), "{text}"); - let basic = crate::http::basic_auth("me", FAKE_SECRET); - for secret in [FAKE_SECRET, FAKE_TOKEN, basic.as_str(), &basic[6..]] { - assert!(!text.contains(secret), "{text}"); - } - } - - #[test] - fn oci_paths_reject_rootfs_and_inconsistent_archives() { - let scratch = scratch_dir(); - let layer = oci_test_layer(false); - let other = - tar_archive([("other", b"other\n".as_slice(), 0o644)].into_iter()).expect("tar"); - let config = oci_config("amd64", &[&layer]); - let twice = oci_config("amd64", &[&layer, &layer]); - let both = |name: &str| { - let image = scratch.path().join(name); - let outdir = scratch.path().join(name.replace('.', "_")); - std::fs::create_dir_all(&outdir).expect("outdir"); - let id = oci_declared_image_id(&image, "linux/amd64"); - let layout = oci_build_layout( - &image, - &outdir, - "registry.invalid", - "demo/app", - "1.0.0", - "linux/amd64", - ); - (id, layout, outdir) - }; - let manifest_one = b"[{\"Config\":\"c.json\",\"Layers\":[\"a/layer.tar\"]}]"; - let manifest_two = - b"[{\"Config\":\"c.json\",\"Layers\":[\"a/layer.tar\",\"b/layer.tar\"]}]"; - std::fs::write(scratch.path().join("rootfs.tar"), &layer).expect("rootfs"); - oci_tar( - &scratch.path().join("missing.tar"), - &[("manifest.json", manifest_one), ("c.json", &config)], - &[], - ); - oci_tar( - &scratch.path().join("mismatch.tar"), - &[ - ("manifest.json", manifest_one), - ("c.json", &config), - ("a/layer.tar", &other), - ], - &[], - ); - oci_tar( - &scratch.path().join("escape.tar"), - &[("manifest.json", manifest_one), ("c.json", &config)], - &[("a/layer.tar", "../../etc/passwd")], - ); - for (name, want) in [ - ("rootfs.tar", "no manifest.json"), - ("missing.tar", "missing its layer 'a/layer.tar'"), - ("mismatch.tar", "diff_ids do not match"), - ("escape.tar", "escapes the archive"), - ] { - let (id, layout, outdir) = both(name); - let id = id.expect_err(name); - assert!(id.contains(want), "{name}: {id}"); - let error = layout.expect_err(name); - assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{name}"); - assert!(error.to_string().contains(want), "{name}: {error}"); - assert!( - std::fs::read_dir(&outdir).expect("outdir").next().is_none(), - "{name}" - ); - } - let id_name = dx_digest::sha256_hex(&config); - let legacy_manifest = - format!("[{{\"Config\":\"{id_name}.json\",\"Layers\":[\"{id_name}/layer.tar\"]}}]"); - let legacy_config = format!("{id_name}.json"); - let legacy_layer = format!("{id_name}/layer.tar"); - oci_tar( - &scratch.path().join("legacy.tar"), - &[ - ("manifest.json", legacy_manifest.as_bytes()), - (legacy_config.as_str(), &config), - (legacy_layer.as_str(), &layer), - ], - &[], - ); - let blob_manifest = - format!("[{{\"Config\":\"blobs/sha256/{id_name}\",\"Layers\":[\"./a/layer.tar\"]}}]"); - let blob_config = format!("blobs/sha256/{id_name}"); - oci_tar( - &scratch.path().join("blobs.tar"), - &[ - ("manifest.json", blob_manifest.as_bytes()), - (blob_config.as_str(), &config), - ("a/layer.tar", &layer), - ], - &[], - ); - oci_tar( - &scratch.path().join("linked.tar"), - &[ - ("manifest.json", manifest_two), - ("c.json", &twice), - ("a/layer.tar", &layer), - ], - &[("b/layer.tar", "../a/layer.tar")], - ); - for (name, body) in [ - ("legacy.tar", &config), - ("blobs.tar", &config), - ("linked.tar", &twice), - ] { - let want = format!("sha256:{}", dx_digest::sha256_hex(body)); - let (id, layout, _) = both(name); - assert_eq!(id.expect(name), want, "{name}"); - let (manifest, _) = oci_layout_json(&layout.expect(name)); - assert_eq!(manifest["config"]["digest"], want.as_str(), "{name}"); - } - } - - #[test] - fn archive_image_writes_a_deterministic_docker_save_archive() { - let scratch = scratch_dir(); - let plain = scratch.path().join("plain.tar"); - let gz = scratch.path().join("gz.tar.gz"); - std::fs::write(&plain, oci_test_layer(false)).expect("plain"); - std::fs::write(&gz, oci_test_layer(true)).expect("gz"); - let first = scratch.path().join("first.tar"); - let second = scratch.path().join("second.tar"); - let layers = [plain.as_path(), gz.as_path()]; - let id = archive_image(&layers, "linux/arm64", &["/app/hello"], &first).expect("first"); - let again = archive_image(&layers, "linux/arm64", &["/app/hello"], &second).expect("again"); - assert_eq!(id, again); - assert_eq!( - std::fs::read(&first).expect("first"), - std::fs::read(&second).expect("second") - ); - let archive = oci_read_archive(&first, None, None).expect("read"); - assert_eq!(archive.config_digest, id); - let config: serde_json::Value = serde_json::from_slice(&archive.config).expect("config"); - assert_eq!(config["architecture"], "arm64"); - assert_eq!(config["os"], "linux"); - assert_eq!(config["created"], "1970-01-01T00:00:00Z"); - assert_eq!( - config["config"]["Entrypoint"], - serde_json::json!(["/app/hello"]) - ); - let tar_digest = format!("sha256:{}", dx_digest::sha256_hex(&oci_test_layer(false))); - assert_eq!( - config["rootfs"]["diff_ids"], - serde_json::json!([tar_digest, tar_digest]) - ); - assert_eq!(archive.layers.len(), 2); - assert_eq!(archive.layers[0].digest, tar_digest); - assert_ne!(archive.layers[1].digest, tar_digest); - let out = scratch.path().join("bad.tar"); - for (layers, platform) in [ - (&[][..], "linux/amd64"), - (&layers[..], "windows/amd64"), - (&layers[..], ""), - ] { - let error = archive_image(layers, platform, &[], &out).expect_err(platform); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{platform}"); - } - assert!(!out.exists()); - let good = scratch.path().join("good.tar.zst"); - let plain_bytes = std::fs::read(&plain).expect("plain"); - std::fs::write( - &good, - ruzstd::encoding::compress_to_vec( - plain_bytes.as_slice(), - ruzstd::encoding::CompressionLevel::Fastest, - ), - ) - .expect("good zst"); - archive_image(&[good.as_path()], "linux/amd64", &[], &out).expect("valid zstd layer"); - std::fs::remove_file(&out).expect("remove"); - let zst = scratch.path().join("zst.tar.zst"); - let mut zstd = ZSTD_MAGIC.to_vec(); - zstd.extend_from_slice(b"not a real zstd frame"); - std::fs::write(&zst, zstd).expect("zst"); - let error = archive_image(&[plain.as_path(), zst.as_path()], "linux/amd64", &[], &out) - .expect_err("zstd"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert!(error.to_string().contains("is not valid zstd"), "{error}"); - assert!(!out.exists()); - } -} +#[path = "oci_tests.rs"] +mod tests; diff --git a/deploy/rules/src/oci_tests.rs b/deploy/rules/src/oci_tests.rs new file mode 100644 index 0000000000..964f1c5ccc --- /dev/null +++ b/deploy/rules/src/oci_tests.rs @@ -0,0 +1,1979 @@ +use super::*; +use crate::test_support::*; + +#[test] +fn oci_layout_builds_index() { + let scratch = scratch_dir(); + let (image, _) = oci_save( + scratch.path(), + "oci_demo.tar", + &[oci_test_layer(false)], + "linux/amd64", + ); + let outdir = scratch.path().join("out"); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + &image, + &outdir, + "registry.example.invalid", + "demo/app", + "1.2.3", + "linux/amd64", + ) + .expect("layout"); + assert!(layout.join("oci-layout").is_file()); + assert!(layout.join("blobs").join("sha256").is_dir()); + assert!(!outdir.join("app-oci-layout.tmp").exists()); + let index = std::fs::read_to_string(layout.join("index.json")).expect("index"); + assert!(!index.contains('\n'), "index is compact"); + let parsed: serde_json::Value = serde_json::from_str(&index).expect("index parses"); + assert_eq!(parsed["schemaVersion"], 2); + assert_eq!( + parsed["mediaType"], + "application/vnd.oci.image.index.v1+json" + ); + assert_eq!( + parsed["manifests"][0]["annotations"]["org.opencontainers.image.ref.name"], + "registry.example.invalid/demo/app:1.2.3" + ); + let digest = parsed["manifests"][0]["digest"].as_str().expect("digest"); + let manifest = layout.join("blobs").join("sha256").join(&digest[7..]); + let manifest_bytes = std::fs::read(&manifest).expect("manifest"); + assert!(!manifest_bytes.contains(&b'\n'), "manifest is compact"); + assert_eq!(parsed["manifests"][0]["size"], manifest_bytes.len()); + assert_eq!( + digest, + format!("sha256:{}", dx_digest::sha256_hex(&manifest_bytes)) + ); + assert_eq!( + digest, + format!( + "sha256:{}", + dx_digest::sha256_file_hex(&manifest).expect("hex") + ) + ); + let again = oci_build_layout( + &image, + &outdir, + "registry.example.invalid", + "demo/app", + "1.2.3", + "linux/amd64", + ) + .expect("rebuild layout"); + assert_eq!(again, layout); + assert_eq!( + std::fs::read_to_string(layout.join("index.json")).expect("index"), + index + ); +} + +fn oci_tar(path: &Path, files: &[(&str, &[u8])], links: &[(&str, &str)]) { + let mut builder = tar::Builder::new(Vec::new()); + for (name, body) in files { + let header = ustar_header(name, body.len() as u64, 0o644).expect("header"); + builder.append(&header, *body).expect("append"); + } + for (name, target) in links { + let mut header = tar::Header::new_ustar(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_path(name).expect("link path"); + header.set_link_name(target).expect("link target"); + header.set_cksum(); + builder.append(&header, io::empty()).expect("append link"); + } + std::fs::write(path, builder.into_inner().expect("tar")).expect("write archive"); +} + +fn oci_save( + dir: &Path, + name: &str, + layers: &[Vec], + platform: &str, +) -> (std::path::PathBuf, String) { + let mut paths = Vec::new(); + for (index, bytes) in layers.iter().enumerate() { + let path = dir.join(format!("{name}-layer-{index}")); + std::fs::write(&path, bytes).expect("write layer"); + paths.push(path); + } + let refs: Vec<&Path> = paths.iter().map(|path| path.as_path()).collect(); + let archive = dir.join(name); + let id = archive_image(&refs, platform, &["/app/hello"], &archive).expect("archive image"); + if name.ends_with(".gz") { + let tar = std::fs::read(&archive).expect("read archive"); + std::fs::write(&archive, gzip_compress(&tar).expect("gzip")).expect("write gz"); + } + (archive, id) +} + +fn oci_config(architecture: &str, diff_ids: &[&[u8]]) -> Vec { + let ids: Vec = diff_ids + .iter() + .map(|tar| format!("sha256:{}", dx_digest::sha256_hex(tar))) + .collect(); + serde_json::to_vec(&serde_json::json!({ + "architecture": architecture, + "config": {"Entrypoint": ["/app/hello"], "Env": ["PATH=/usr/bin", "DEMO=1"]}, + "os": "linux", + "rootfs": {"diff_ids": ids, "type": "layers"}, + })) + .expect("config") +} + +#[test] +fn oci_declared_image_id_binds_the_single_config_digest() { + let scratch = scratch_dir(); + let archive = scratch.path().join("image.tar"); + let layer = oci_test_layer(false); + let config = oci_config("amd64", &[&layer]); + oci_tar( + &archive, + &[ + ( + "manifest.json", + b"[{\"Config\":\"abc.json\",\"RepoTags\":[\"other:tag\"],\"Layers\":[\"abc/layer.tar\"]}]", + ), + ("abc.json", &config), + ("abc/layer.tar", &layer), + ], + &[], + ); + assert_eq!( + oci_declared_image_id(&archive, "linux/amd64").expect("id"), + format!("sha256:{}", dx_digest::sha256_hex(&config)) + ); + let (saved, id) = oci_save( + scratch.path(), + "saved.tar", + &[oci_test_layer(false), oci_test_layer(true)], + "linux/amd64", + ); + assert_eq!( + oci_declared_image_id(&saved, "linux/amd64").expect("saved id"), + id + ); +} + +#[test] +fn oci_declared_image_id_rejects_unsupported_archives() { + let scratch = scratch_dir(); + let archive = scratch.path().join("image.tar"); + let layer = oci_test_layer(false); + let config = oci_config("amd64", &[&layer]); + oci_tar(&archive, &[("manifest.json", b"[]")], &[]); + assert!( + oci_declared_image_id(&archive, "linux/amd64") + .expect_err("empty") + .contains("exactly one") + ); + oci_tar( + &archive, + &[ + ( + "manifest.json", + b"[{\"Config\":\"a.json\",\"Layers\":[\"l.tar\"]},{\"Config\":\"a.json\",\"Layers\":[\"l.tar\"]}]", + ), + ("a.json", &config), + ("l.tar", &layer), + ], + &[], + ); + assert!( + oci_declared_image_id(&archive, "linux/amd64") + .expect_err("many") + .contains("2 images") + ); + oci_tar( + &archive, + &[ + ( + "manifest.json", + b"[{\"Config\":\"gone.json\",\"Layers\":[\"l.tar\"]}]", + ), + ("a.json", &config), + ("l.tar", &layer), + ], + &[], + ); + assert!( + oci_declared_image_id(&archive, "linux/amd64") + .expect_err("missing config") + .contains("missing its config") + ); + oci_tar( + &archive, + &[ + ("manifest.json", b"[{\"Config\":\"a.json\"}]"), + ("a.json", &config), + ], + &[], + ); + assert!( + oci_declared_image_id(&archive, "linux/amd64") + .expect_err("no layers") + .contains("missing Layers") + ); + oci_tar(&archive, &[("manifest.json", b"{not json")], &[]); + assert!( + oci_declared_image_id(&archive, "linux/amd64") + .expect_err("malformed") + .contains("malformed manifest.json") + ); + std::fs::write(&archive, b"image").expect("rootfs"); + assert!(oci_declared_image_id(&archive, "linux/amd64").is_err()); + let layer = { + let mut builder = tar::Builder::new(Vec::new()); + let mut header = ustar_header("etc/os-release", 2, 0o644).expect("header"); + header.set_cksum(); + builder.append(&header, &b"x\n"[..]).expect("append"); + builder.into_inner().expect("tar") + }; + std::fs::write(&archive, layer).expect("layer"); + assert!( + oci_declared_image_id(&archive, "linux/amd64") + .expect_err("rootfs layer") + .contains("no manifest.json or index.json") + ); +} + +fn oci_digest(bytes: &[u8]) -> String { + format!("sha256:{}", dx_digest::sha256_hex(bytes)) +} + +fn oci_blob(bytes: &[u8]) -> String { + format!("blobs/sha256/{}", dx_digest::sha256_hex(bytes)) +} + +fn oci_descriptor(media_type: &str, body: &[u8]) -> serde_json::Value { + serde_json::json!({ + "digest": oci_digest(body), + "mediaType": media_type, + "size": body.len(), + }) +} + +fn oci_index_body(manifests: &[serde_json::Value]) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "manifests": manifests, + "mediaType": "application/vnd.oci.image.index.v1+json", + "schemaVersion": 2, + })) + .expect("index") +} + +fn oci_manifest_body(config: &[u8], layers: &[serde_json::Value]) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "config": oci_descriptor("application/vnd.oci.image.config.v1+json", config), + "layers": layers, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "schemaVersion": 2, + })) + .expect("manifest") +} + +fn oci_layer_descriptor(layer: &[u8]) -> serde_json::Value { + let media_type = if layer.starts_with(&GZIP_MAGIC) { + "application/vnd.oci.image.layer.v1.tar+gzip" + } else { + "application/vnd.oci.image.layer.v1.tar" + }; + oci_descriptor(media_type, layer) +} + +struct OciIndexFixture { + config: Vec, + manifest: Vec, + members: Vec<(String, Vec)>, +} + +fn oci_index_fixture(layers: &[Vec], architecture: &str, nest: bool) -> OciIndexFixture { + let uncompressed: Vec> = layers + .iter() + .map(|layer| { + if layer.starts_with(&GZIP_MAGIC) { + let mut tar = Vec::new(); + flate2::read::MultiGzDecoder::new(layer.as_slice()) + .read_to_end(&mut tar) + .expect("gunzip"); + tar + } else { + layer.clone() + } + }) + .collect(); + let diff_ids: Vec<&[u8]> = uncompressed.iter().map(Vec::as_slice).collect(); + let config = oci_config(architecture, &diff_ids); + let descriptors: Vec = layers + .iter() + .map(|layer| oci_layer_descriptor(layer)) + .collect(); + let manifest = oci_manifest_body(&config, &descriptors); + let mut image = oci_descriptor("application/vnd.oci.image.manifest.v1+json", &manifest); + image["platform"] = serde_json::json!({"architecture": architecture, "os": "linux"}); + let mut members = vec![ + ( + "oci-layout".to_owned(), + b"{\"imageLayoutVersion\":\"1.0.0\"}".to_vec(), + ), + (oci_blob(&manifest), manifest.clone()), + (oci_blob(&config), config.clone()), + ]; + for layer in layers { + members.push((oci_blob(layer), layer.clone())); + } + let top = if nest { + let statement = b"{\"_type\":\"https://in-toto.io/Statement/v0.1\"}".to_vec(); + let attestation = oci_manifest_body( + b"{}", + &[oci_descriptor("application/vnd.in-toto+json", &statement)], + ); + let mut skipped = + oci_descriptor("application/vnd.oci.image.manifest.v1+json", &attestation); + skipped["annotations"] = serde_json::json!({ + "vnd.docker.reference.digest": oci_digest(&manifest), + "vnd.docker.reference.type": "attestation-manifest", + }); + let inner = oci_index_body(&[image, skipped]); + members.push((oci_blob(&attestation), attestation)); + members.push((oci_blob(&statement), statement)); + members.push((oci_blob(&inner), inner.clone())); + oci_descriptor("application/vnd.oci.image.index.v1+json", &inner) + } else { + image + }; + members.push(("index.json".to_owned(), oci_index_body(&[top]))); + OciIndexFixture { + config, + manifest, + members, + } +} + +fn oci_index_members( + fixture: &OciIndexFixture, + extra: &[(&str, &[u8])], + links: &[(&str, &str)], +) -> Vec<(String, Vec)> { + let mut members: Vec<(String, Vec)> = fixture + .members + .iter() + .filter(|(name, _)| { + !extra.iter().any(|(other, _)| other == name) + && !links.iter().any(|(other, _)| other == name) + }) + .cloned() + .collect(); + members.extend( + extra + .iter() + .map(|(name, body)| ((*name).to_owned(), body.to_vec())), + ); + members +} + +fn oci_index_tar( + path: &Path, + layers: &[Vec], + architecture: &str, + nest: bool, + extra: &[(&str, &[u8])], + links: &[(&str, &str)], +) -> OciIndexFixture { + let fixture = oci_index_fixture(layers, architecture, nest); + let members = oci_index_members(&fixture, extra, links); + let files: Vec<(&str, &[u8])> = members + .iter() + .map(|(name, body)| (name.as_str(), body.as_slice())) + .collect(); + oci_tar(path, &files, links); + if path.to_string_lossy().ends_with(".gz") { + let tar = std::fs::read(path).expect("read archive"); + std::fs::write(path, gzip_compress(&tar).expect("gzip")).expect("write gz"); + } + fixture +} + +fn oci_write_dir(dir: &Path, members: &[(String, Vec)], links: &[(&str, &str)]) { + if dir.exists() { + std::fs::remove_dir_all(dir).expect("clear dir"); + } + std::fs::create_dir_all(dir).expect("dir"); + for (name, body) in members { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().expect("parent")).expect("parents"); + std::fs::write(path, body).expect("write member"); + } + for (name, target) in links { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().expect("parent")).expect("parents"); + #[cfg(unix)] + std::os::unix::fs::symlink(target, path).expect("symlink"); + #[cfg(not(unix))] + std::fs::copy( + dir.join(name).parent().expect("parent").join(target), + dir.join(name), + ) + .expect("copy link target"); + } +} + +fn oci_index_dir( + dir: &Path, + layers: &[Vec], + architecture: &str, + nest: bool, + extra: &[(&str, &[u8])], + links: &[(&str, &str)], +) -> OciIndexFixture { + let fixture = oci_index_fixture(layers, architecture, nest); + oci_write_dir(dir, &oci_index_members(&fixture, extra, links), links); + fixture +} + +#[test] +fn oci_archive_image_id_reads_oci_layout_directories() { + let scratch = scratch_dir(); + let layers = [oci_test_layer(false), oci_test_layer(true)]; + for (name, nest) in [("flat", false), ("nested", true)] { + let dir = scratch.path().join(name); + let fixture = oci_index_dir(&dir, &layers, "amd64", nest, &[], &[]); + let tar = scratch.path().join(format!("{name}.tar")); + oci_index_tar(&tar, &layers, "amd64", nest, &[], &[]); + assert_eq!( + oci_declared_image_id(&dir, "linux/amd64").expect(name), + oci_digest(&fixture.config), + "{name}" + ); + assert_eq!( + oci_read_archive(&dir, None, None).expect(name), + oci_read_archive(&tar, None, None).expect(name), + "{name}" + ); + } + let dir = scratch.path().join("linked"); + let fixture = oci_index_fixture(&layers, "amd64", true); + let manifest_blob = oci_blob(&fixture.manifest); + let config_blob = oci_blob(&fixture.config); + let layer_blob = oci_blob(&layers[1]); + oci_index_dir( + &dir, + &layers, + "amd64", + true, + &[ + ("store/manifest.json", &fixture.manifest), + ("store/config.json", &fixture.config), + ("store/layer.tar.gz", &layers[1]), + ], + &[ + (&manifest_blob, "../../store/manifest.json"), + (&config_blob, "../../store/config.json"), + (&layer_blob, "../../store/layer.tar.gz"), + ], + ); + assert_eq!( + oci_declared_image_id(&dir, "linux/amd64").expect("linked"), + oci_digest(&fixture.config) + ); + let docker_manifest = serde_json::to_vec(&serde_json::json!([{ + "Config": oci_blob(&fixture.config), + "Layers": layers.iter().map(|layer| oci_blob(layer)).collect::>(), + }])) + .expect("docker manifest"); + let dir = scratch.path().join("docker25"); + oci_index_dir( + &dir, + &layers, + "amd64", + true, + &[ + ("manifest.json", &docker_manifest), + ("index.json", b"{not json"), + ], + &[], + ); + assert_eq!( + oci_declared_image_id(&dir, "linux/amd64").expect("docker25"), + oci_digest(&fixture.config) + ); +} + +#[test] +fn oci_archive_image_id_picks_the_deploy_platform_from_an_index() { + let scratch = scratch_dir(); + let amd = oci_index_fixture( + &[oci_test_layer(false), oci_test_layer(true)], + "amd64", + false, + ); + let arm = oci_index_fixture(&[oci_test_layer(false)], "arm64", false); + let mut by_name = std::collections::BTreeMap::new(); + for (name, body) in amd.members.iter().chain(arm.members.iter()) { + if name != "index.json" { + by_name.insert(name.clone(), body.clone()); + } + } + let image = |manifest: &[u8], architecture: &str| { + let mut descriptor = oci_descriptor("application/vnd.oci.image.manifest.v1+json", manifest); + descriptor["platform"] = serde_json::json!({"architecture": architecture, "os": "linux"}); + descriptor + }; + let index = oci_index_body(&[ + image(amd.manifest.as_slice(), "amd64"), + image(arm.manifest.as_slice(), "arm64"), + ]); + by_name.insert("index.json".to_owned(), index); + let members: Vec<(String, Vec)> = by_name.into_iter().collect(); + let dir = scratch.path().join("multi"); + oci_write_dir(&dir, &members, &[]); + let tar = scratch.path().join("multi.tar"); + let files: Vec<(&str, &[u8])> = members + .iter() + .map(|(name, body)| (name.as_str(), body.as_slice())) + .collect(); + oci_tar(&tar, &files, &[]); + for path in [&dir, &tar] { + assert_eq!( + oci_declared_image_id(path, "linux/amd64").expect("amd64"), + oci_digest(&amd.config), + "{}", + path.display() + ); + assert_eq!( + oci_declared_image_id(path, "linux/arm64").expect("arm64"), + oci_digest(&arm.config), + "{}", + path.display() + ); + } + assert!(oci_read_archive(&dir, None, None).is_err()); + assert!(oci_read_archive(&tar, None, None).is_err()); +} + +#[test] +fn oci_archive_image_id_rejects_unsupported_oci_layout_directories() { + let scratch = scratch_dir(); + let dir = scratch.path().join("layout"); + let layers = [oci_test_layer(false)]; + let fixture = oci_index_fixture(&layers, "amd64", false); + let image = oci_descriptor( + "application/vnd.oci.image.manifest.v1+json", + &fixture.manifest, + ); + let other = oci_descriptor("application/vnd.oci.image.manifest.v1+json", b"other"); + let two = oci_index_body(&[image.clone(), other]); + let gone = oci_index_body(&[oci_descriptor( + "application/vnd.oci.image.manifest.v1+json", + b"gone", + )]); + let mut deep: Vec<(String, Vec)> = Vec::new(); + let mut top = image.clone(); + for _ in 0..=OCI_INDEX_DEPTH { + let body = oci_index_body(&[top]); + top = oci_descriptor("application/vnd.oci.image.index.v1+json", &body); + deep.push((oci_blob(&body), body)); + } + deep.push(("index.json".to_owned(), oci_index_body(&[top]))); + let deep: Vec<(&str, &[u8])> = deep + .iter() + .map(|(name, body)| (name.as_str(), body.as_slice())) + .collect(); + let manifest_blob = oci_blob(&fixture.manifest); + let layer_blob = oci_blob(&layers[0]); + let docker = |config: &str| { + serde_json::to_vec(&serde_json::json!([{ + "Config": config, + "Layers": [layer_blob.clone()], + }])) + .expect("docker manifest") + }; + let (dotdot, drive, backslash) = (docker("../x"), docker("C:/x"), docker("a\\b")); + let cases: Vec<(&str, Vec<(&str, &[u8])>, &str)> = vec![ + ( + "two images", + vec![("index.json", two.as_slice())], + "2 images", + ), + ( + "missing manifest", + vec![("index.json", gone.as_slice())], + "is missing its blob 'sha256:", + ), + ( + "manifest mismatch", + vec![(manifest_blob.as_str(), &b"{}"[..])], + "does not match its digest", + ), + ("too deep", deep, "deeper than 8 levels"), + ( + "dotdot", + vec![("manifest.json", dotdot.as_slice())], + "path '../x' escapes the archive", + ), + ( + "drive", + vec![("manifest.json", drive.as_slice())], + "missing its config blob 'C:/x'", + ), + ( + "backslash", + vec![("manifest.json", backslash.as_slice())], + "missing its config blob 'a\\b'", + ), + ]; + for (case, extra, want) in cases { + oci_index_dir(&dir, &layers, "amd64", false, &extra, &[]); + let error = oci_declared_image_id(&dir, "linux/amd64").expect_err(case); + assert!(error.contains(want), "{case}: {error}"); + } + std::fs::remove_dir_all(&dir).expect("clear"); + std::fs::create_dir_all(&dir).expect("empty dir"); + assert_eq!( + oci_declared_image_id(&dir, "linux/amd64").expect_err("empty"), + "image directory has no index.json or manifest.json; want an OCI image layout directory" + ); + let bare = scratch.path().join("layout.bin"); + std::fs::write(&bare, b"image").expect("bare"); + assert_eq!( + oci_declared_image_id(&bare, "linux/amd64").expect_err("suffix"), + format!( + "oci image: want a .tar or .tar.gz archive or an OCI layout directory, got '{}'", + bare.display() + ) + ); +} + +#[test] +fn oci_layout_reads_oci_layout_directories() { + let scratch = scratch_dir(); + let layers = [oci_test_layer(false), oci_test_layer(true)]; + let tar = scratch.path().join("index.tar"); + oci_index_tar(&tar, &layers, "amd64", true, &[], &[]); + let dir = scratch.path().join("index"); + oci_index_dir(&dir, &layers, "amd64", true, &[], &[]); + let mut built = Vec::new(); + for (name, image) in [("tar", &tar), ("dir", &dir)] { + let outdir = scratch.path().join(format!("out-{name}")); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect(name); + built.push(oci_layout_json(&layout)); + } + assert_eq!(built[0], built[1]); + let arm = scratch.path().join("arm"); + oci_index_dir(&arm, &layers, "arm64", false, &[], &[]); + let outdir = scratch.path().join("out-arm"); + std::fs::create_dir_all(&outdir).expect("outdir"); + let error = oci_build_layout( + &arm, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect_err("arm64 config"); + assert!( + error.to_string().contains("image config is 'linux/arm64'"), + "{error}" + ); +} + +#[test] +fn oci_archive_image_id_reads_oci_layout_archives() { + let scratch = scratch_dir(); + let layers = [oci_test_layer(false), oci_test_layer(true)]; + for (name, nest) in [ + ("flat.tar", false), + ("nested.tar", true), + ("flat.tar.gz", false), + ("nested.tar.gz", true), + ] { + let archive = scratch.path().join(name); + let fixture = oci_index_tar(&archive, &layers, "amd64", nest, &[], &[]); + assert_eq!( + oci_declared_image_id(&archive, "linux/amd64").expect(name), + oci_digest(&fixture.config), + "{name}" + ); + } + let archive = scratch.path().join("linked.tar"); + let fixture = oci_index_fixture(&layers, "amd64", true); + let manifest_blob = oci_blob(&fixture.manifest); + let config_blob = oci_blob(&fixture.config); + let layer_blob = oci_blob(&layers[1]); + oci_index_tar( + &archive, + &layers, + "amd64", + true, + &[ + ("store/manifest.json", &fixture.manifest), + ("store/config.json", &fixture.config), + ("store/layer.tar.gz", &layers[1]), + ], + &[ + (&manifest_blob, "../../store/manifest.json"), + (&config_blob, "../../store/config.json"), + (&layer_blob, "../../store/layer.tar.gz"), + ], + ); + assert_eq!( + oci_declared_image_id(&archive, "linux/amd64").expect("linked"), + oci_digest(&fixture.config) + ); + let fixture = oci_index_fixture(&layers, "amd64", true); + let docker_manifest = serde_json::to_vec(&serde_json::json!([{ + "Config": oci_blob(&fixture.config), + "Layers": layers.iter().map(|layer| oci_blob(layer)).collect::>(), + "RepoTags": ["demo/app:1.0.0"], + }])) + .expect("docker manifest"); + for (name, index) in [ + ("docker25.tar", None), + ("docker25_index_ignored.tar", Some(b"{not json".as_slice())), + ] { + let archive = scratch.path().join(name); + let mut extra: Vec<(&str, &[u8])> = vec![("manifest.json", docker_manifest.as_slice())]; + if let Some(index) = index { + extra.push(("index.json", index)); + } + oci_index_tar(&archive, &layers, "amd64", true, &extra, &[]); + assert_eq!( + oci_declared_image_id(&archive, "linux/amd64").expect(name), + oci_digest(&fixture.config), + "{name}" + ); + } +} + +#[test] +fn oci_archive_image_id_rejects_unsupported_oci_layout_archives() { + let scratch = scratch_dir(); + let archive = scratch.path().join("image.tar"); + let layers = [oci_test_layer(false)]; + let fixture = oci_index_fixture(&layers, "amd64", false); + let image = oci_descriptor( + "application/vnd.oci.image.manifest.v1+json", + &fixture.manifest, + ); + let config_blob = oci_blob(&fixture.config); + let layer_blob = oci_blob(&layers[0]); + let other_config = oci_config("amd64", &[&oci_test_layer(true)]); + let other_layer = oci_test_layer(true); + let missing = oci_descriptor("application/vnd.oci.image.manifest.v1+json", b"gone"); + let with_digest = |digest: &str| { + let mut descriptor = image.clone(); + descriptor["digest"] = serde_json::json!(digest); + oci_index_body(&[descriptor]) + }; + let other = oci_descriptor("application/vnd.oci.image.manifest.v1+json", b"other"); + let two = oci_index_body(&[image.clone(), other]); + let gone = oci_index_body(&[missing]); + let md5 = with_digest("md5:0123456789abcdef0123456789abcdef"); + let escape = with_digest("sha256:../x"); + let upper = with_digest( + &oci_digest(&fixture.manifest) + .to_uppercase() + .replace("SHA256", "sha256"), + ); + let mut foreign = image.clone(); + foreign["mediaType"] = serde_json::json!("application/vnd.example.manifest+json"); + let foreign = oci_index_body(&[foreign]); + let manifest_with = |layers: &[serde_json::Value]| { + let manifest = oci_manifest_body(&fixture.config, layers); + let index = oci_index_body(&[oci_descriptor( + "application/vnd.oci.image.manifest.v1+json", + &manifest, + )]); + (oci_blob(&manifest), manifest, index) + }; + let mut nondistributable = oci_layer_descriptor(&layers[0]); + nondistributable["mediaType"] = + serde_json::json!("application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"); + let nondistributable = manifest_with(&[nondistributable]); + let empty = manifest_with(&[]); + let mut deep: Vec<(String, Vec)> = Vec::new(); + let mut top = image.clone(); + for _ in 0..=OCI_INDEX_DEPTH { + let body = oci_index_body(&[top]); + top = oci_descriptor("application/vnd.oci.image.index.v1+json", &body); + deep.push((oci_blob(&body), body)); + } + deep.push(("index.json".to_owned(), oci_index_body(&[top]))); + let deep: Vec<(&str, &[u8])> = deep + .iter() + .map(|(name, body)| (name.as_str(), body.as_slice())) + .collect(); + let manifest_blob = oci_blob(&fixture.manifest); + let cases: Vec<(&str, Vec<(&str, &[u8])>, &str)> = vec![ + ( + "two images", + vec![("index.json", two.as_slice())], + "2 images", + ), + ( + "malformed", + vec![("index.json", &b"{not json"[..])], + "malformed index.json", + ), + ( + "no manifests", + vec![("index.json", &b"{}"[..])], + "malformed index.json", + ), + ( + "missing manifest", + vec![("index.json", gone.as_slice())], + "is missing its blob 'sha256:", + ), + ( + "manifest mismatch", + vec![(manifest_blob.as_str(), &b"{}"[..])], + "does not match its digest", + ), + ( + "config mismatch", + vec![(config_blob.as_str(), other_config.as_slice())], + "does not match its digest", + ), + ( + "layer mismatch", + vec![(layer_blob.as_str(), other_layer.as_slice())], + "does not match its digest", + ), + ( + "md5", + vec![("index.json", md5.as_slice())], + "not a sha256 digest", + ), + ( + "escape", + vec![("index.json", escape.as_slice())], + "not a sha256 digest", + ), + ( + "uppercase", + vec![("index.json", upper.as_slice())], + "not a sha256 digest", + ), + ( + "nondistributable", + vec![ + ("index.json", nondistributable.2.as_slice()), + (nondistributable.0.as_str(), nondistributable.1.as_slice()), + ], + "unsupported layer media type", + ), + ( + "empty layers", + vec![ + ("index.json", empty.2.as_slice()), + (empty.0.as_str(), empty.1.as_slice()), + ], + "malformed image manifest: missing layers", + ), + ("too deep", deep, "deeper than 8 levels"), + ( + "foreign manifest", + vec![("index.json", foreign.as_slice())], + "unsupported manifest media type 'application/vnd.example.manifest+json'", + ), + ]; + for (case, extra, want) in cases { + oci_index_tar(&archive, &layers, "amd64", false, &extra, &[]); + let error = oci_declared_image_id(&archive, "linux/amd64").expect_err(case); + assert!(error.contains(want), "{case}: {error}"); + } + oci_index_tar(&archive, &layers, "amd64", false, &[], &[]); + assert_eq!( + oci_declared_image_id(&archive, "linux/amd64").expect("valid"), + oci_digest(&fixture.config) + ); + // The same manifest under two tags, or twice in a nested index, is one image. + let mut tagged = Vec::new(); + for tag in ["one", "two"] { + let mut descriptor = image.clone(); + descriptor["annotations"] = serde_json::json!({"org.opencontainers.image.ref.name": tag}); + tagged.push(descriptor); + } + let tagged_index = oci_index_body(&tagged); + let nested_body = oci_index_body(&[image.clone(), image.clone()]); + let nested_blob = oci_blob(&nested_body); + let nested_index = oci_index_body(&[oci_descriptor( + "application/vnd.oci.image.index.v1+json", + &nested_body, + )]); + let positives: Vec<(&str, Vec<(&str, &[u8])>)> = vec![ + ("two tags", vec![("index.json", tagged_index.as_slice())]), + ( + "nested twice", + vec![ + ("index.json", nested_index.as_slice()), + (nested_blob.as_str(), nested_body.as_slice()), + ], + ), + ]; + for (case, extra) in positives { + oci_index_tar(&archive, &layers, "amd64", false, &extra, &[]); + assert_eq!( + oci_declared_image_id(&archive, "linux/amd64").expect(case), + oci_digest(&fixture.config), + "{case}" + ); + } +} + +#[test] +fn oci_layout_reads_oci_layout_archives() { + let scratch = scratch_dir(); + let layers = [oci_test_layer(false), oci_test_layer(true)]; + let index_tar = scratch.path().join("index.tar"); + let fixture = oci_index_tar(&index_tar, &layers, "amd64", true, &[], &[]); + let docker_manifest = serde_json::to_vec(&serde_json::json!([{ + "Config": "config.json", + "Layers": ["0/layer.tar", "1/layer.tar"], + }])) + .expect("docker manifest"); + let docker_tar = scratch.path().join("docker.tar"); + oci_tar( + &docker_tar, + &[ + ("manifest.json", &docker_manifest), + ("config.json", &fixture.config), + ("0/layer.tar", &layers[0]), + ("1/layer.tar", &layers[1]), + ], + &[], + ); + let mut built = Vec::new(); + for (name, image) in [("index", &index_tar), ("docker", &docker_tar)] { + let outdir = scratch.path().join(name); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect(name); + built.push(oci_layout_json(&layout)); + for layer in &layers { + let stored = layout + .join("blobs") + .join("sha256") + .join(dx_digest::sha256_hex(layer)); + assert_eq!(std::fs::read(stored).expect("blob"), *layer, "{name}"); + } + } + let (manifest, config) = &built[0]; + assert_eq!(built[0], built[1]); + assert_eq!( + manifest["config"]["digest"], + oci_digest(&fixture.config).as_str() + ); + let digests: Vec = layers.iter().map(|layer| oci_digest(layer)).collect(); + assert_eq!(manifest["layers"][0]["digest"], digests[0].as_str()); + assert_eq!(manifest["layers"][1]["digest"], digests[1].as_str()); + assert_eq!( + manifest["layers"][0]["mediaType"], + "application/vnd.oci.image.layer.v1.tar" + ); + assert_eq!( + manifest["layers"][1]["mediaType"], + "application/vnd.oci.image.layer.v1.tar+gzip" + ); + assert_eq!(config["architecture"], "amd64"); + let arm = scratch.path().join("arm.tar"); + oci_index_tar(&arm, &layers, "arm64", false, &[], &[]); + let outdir = scratch.path().join("arm"); + std::fs::create_dir_all(&outdir).expect("outdir"); + let error = oci_build_layout( + &arm, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect_err("arm64 config"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!( + error.to_string().contains("image config is 'linux/arm64'"), + "{error}" + ); + assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); +} + +#[test] +fn oci_layout_rejects_unusable_registry_repository_and_tag() { + let scratch = scratch_dir(); + let outdir = scratch.path().join("out"); + std::fs::create_dir_all(&outdir).expect("outdir"); + let (image, _) = oci_save( + scratch.path(), + "oci_demo.tar", + &[oci_test_layer(false)], + "linux/amd64", + ); + for registry in ["", "https://registry.invalid"] { + let error = oci_build_layout( + &image, + &outdir, + registry, + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect_err("registry host"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{registry}"); + } + for repository in ["", "/demo/app", "demo/app/"] { + let error = oci_build_layout( + &image, + &outdir, + "registry.invalid", + repository, + "1.0.0", + "linux/amd64", + ) + .expect_err("repository path"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{repository}"); + } + let error = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "", + "linux/amd64", + ) + .expect_err("empty tag"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); +} + +fn oci_layout_json(layout: &Path) -> (serde_json::Value, serde_json::Value) { + let blobs = layout.join("blobs").join("sha256"); + let read = |digest: &str| { + let body = std::fs::read_to_string(blobs.join(&digest[7..])).expect("blob"); + serde_json::from_str::(&body).expect("json") + }; + let index = std::fs::read_to_string(layout.join("index.json")).expect("index"); + let index: serde_json::Value = serde_json::from_str(&index).expect("index parses"); + let manifest = read(index["manifests"][0]["digest"].as_str().expect("digest")); + let config = read(manifest["config"]["digest"].as_str().expect("config")); + (manifest, config) +} + +#[test] +fn oci_layout_diff_id_is_the_uncompressed_digest() { + let scratch = scratch_dir(); + let tar = oci_test_layer(false); + let gz = oci_test_layer(true); + let tar_digest = format!("sha256:{}", dx_digest::sha256_hex(&tar)); + let gz_digest = format!("sha256:{}", dx_digest::sha256_hex(&gz)); + assert_ne!(tar_digest, gz_digest); + for (name, bytes, blob_digest) in [ + ("plain.tar", &tar, &tar_digest), + ("gzip.tar", &gz, &gz_digest), + ] { + let (image, _) = oci_save( + scratch.path(), + name, + std::slice::from_ref(bytes), + "linux/amd64", + ); + let outdir = scratch.path().join(name.replace('.', "_")); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect("layout"); + let (manifest, config) = oci_layout_json(&layout); + assert_eq!( + manifest["layers"][0]["digest"], + blob_digest.as_str(), + "{name}" + ); + assert_eq!(manifest["layers"][0]["size"], bytes.len(), "{name}"); + assert_eq!( + config["rootfs"]["diff_ids"][0], + tar_digest.as_str(), + "{name}" + ); + let stored = layout.join("blobs").join("sha256").join(&blob_digest[7..]); + assert_eq!(std::fs::read(stored).expect("blob"), *bytes, "{name}"); + } +} + +#[test] +fn oci_layout_rejects_malformed_gzip_layers() { + let scratch = scratch_dir(); + let gz = oci_test_layer(true); + let truncated = gz[..gz.len() - 12].to_vec(); + let mut corrupt = gz.clone(); + let mid = corrupt.len() / 2; + corrupt[mid] ^= 0xff; + let tar = oci_test_layer(false); + let config = oci_config("amd64", &[&tar]); + let mut cases: Vec<(String, std::path::PathBuf)> = Vec::new(); + for (name, bytes) in [ + ("not_gzip", b"not gzip at all".to_vec()), + ("truncated", truncated), + ("corrupt", corrupt), + ] { + let image = scratch.path().join(format!("{name}.tar")); + oci_tar( + &image, + &[ + ( + "manifest.json", + b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", + ), + ("c.json", &config), + ("l/layer.tar", &bytes), + ], + &[], + ); + cases.push((name.to_owned(), image)); + } + let (valid, _) = oci_save(scratch.path(), "valid.tar", &[tar.clone()], "linux/amd64"); + let outer = gzip_compress(&std::fs::read(&valid).expect("read valid")).expect("gzip"); + let mut flipped = outer.clone(); + let mid = flipped.len() / 2; + flipped[mid] ^= 0xff; + for (name, bytes) in [ + ("outer_truncated", outer[..outer.len() / 2].to_vec()), + ("outer_corrupt", flipped), + ] { + let image = scratch.path().join(format!("{name}.tar.gz")); + std::fs::write(&image, bytes).expect("write image"); + cases.push((name.to_owned(), image)); + } + for (name, image) in cases { + let outdir = scratch.path().join(&name); + std::fs::create_dir_all(&outdir).expect("outdir"); + let error = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect_err(&name); + assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{name}"); + assert!( + oci_declared_image_id(&image, "linux/amd64").is_err(), + "{name}" + ); + assert!( + std::fs::read_dir(&outdir).expect("outdir").next().is_none(), + "{name}" + ); + } +} + +#[test] +fn oci_paths_reject_invalid_zstd() { + let scratch = scratch_dir(); + let mut zstd = ZSTD_MAGIC.to_vec(); + zstd.extend_from_slice(b"not a real zstd frame"); + let tar = oci_test_layer(false); + let config = oci_config("amd64", &[&tar]); + let layer = scratch.path().join("zstd_layer.tar"); + oci_tar( + &layer, + &[ + ( + "manifest.json", + b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", + ), + ("c.json", &config), + ("l/layer.tar", &zstd), + ], + &[], + ); + let (valid, _) = oci_save(scratch.path(), "valid.tar", &[tar.clone()], "linux/amd64"); + let mut outer = ZSTD_MAGIC.to_vec(); + outer.extend_from_slice(&std::fs::read(&valid).expect("read valid")); + let archive = scratch.path().join("zstd_image.tar"); + std::fs::write(&archive, outer).expect("write image"); + for (name, image, want) in [ + ("layer", layer, "is not valid zstd"), + ( + "archive", + archive, + "unsupported image archive compression zstd", + ), + ] { + let outdir = scratch.path().join(name); + std::fs::create_dir_all(&outdir).expect("outdir"); + let error = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect_err(name); + assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{name}"); + let message = error.to_string(); + assert!(message.contains(want), "{name}: {message}"); + assert!( + !message.contains("diff_ids do not match"), + "{name}: {message}" + ); + let id_error = oci_declared_image_id(&image, "linux/amd64").expect_err(name); + assert!(id_error.contains(want), "{name}: {id_error}"); + assert!( + std::fs::read_dir(&outdir).expect("outdir").next().is_none(), + "{name}" + ); + } +} + +fn zstd_frame(data: &[u8]) -> Vec { + ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) +} + +fn skippable_frame(length: u32, body: &[u8]) -> Vec { + let mut frame = vec![0x50, 0x2A, 0x4D, 0x18]; + frame.extend_from_slice(&length.to_le_bytes()); + frame.extend_from_slice(body); + frame +} + +#[test] +fn oci_read_layer_hashes_every_zstd_frame() { + let (a, b) = (b"first frame data".to_vec(), b"second frame data".to_vec()); + let joined = [a.clone(), b.clone()].concat(); + let want = format!("sha256:{}", dx_digest::sha256_hex(&joined)); + let plain = [zstd_frame(&a), zstd_frame(&b)].concat(); + let skipped = [zstd_frame(&a), skippable_frame(4, b"junk"), zstd_frame(&b)].concat(); + for (name, blob) in [("plain", plain), ("skippable", skipped)] { + let layer = oci_read_layer(blob.as_slice(), name, None).expect(name); + assert_eq!(layer.diff_id, want, "{name}"); + } +} + +#[test] +fn oci_read_layer_rejects_bad_zstd_tails() { + let good = zstd_frame(b"frame"); + let mut garbage = good.clone(); + garbage.extend_from_slice(b"trailing garbage"); + let mut short = good; + short.extend_from_slice(&skippable_frame(8, b"abc")); + for (name, blob) in [("garbage", garbage), ("short", short)] { + let error = oci_read_layer(blob.as_slice(), name, None) + .err() + .expect(name); + assert!(error.to_string().contains("is not valid zstd"), "{error}"); + } +} + +#[test] +fn oci_paths_accept_multi_frame_zstd_layer() { + let scratch = scratch_dir(); + let tar = oci_test_layer(false); + let (head, tail) = tar.split_at(tar.len() / 2); + let zstd = [zstd_frame(head), zstd_frame(tail)].concat(); + let config = oci_config("amd64", &[&tar]); + let image = scratch.path().join("multi_frame.tar"); + oci_tar( + &image, + &[ + ( + "manifest.json", + b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", + ), + ("c.json", &config), + ("l/layer.tar", &zstd), + ], + &[], + ); + let outdir = scratch.path().join("out"); + std::fs::create_dir_all(&outdir).expect("outdir"); + oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect("multi-frame layer"); +} + +#[test] +fn archive_image_hashes_every_zstd_frame() { + let scratch = scratch_dir(); + let tar = oci_test_layer(false); + let (head, tail) = tar.split_at(tar.len() / 2); + let layer = scratch.path().join("multi.tar.zst"); + std::fs::write(&layer, [zstd_frame(head), zstd_frame(tail)].concat()).expect("layer"); + let out = scratch.path().join("out.tar"); + archive_image(&[layer.as_path()], "linux/amd64", &[], &out).expect("archive"); + let archive = oci_read_archive(&out, None, None).expect("read"); + let config: serde_json::Value = serde_json::from_slice(&archive.config).expect("config"); + assert_eq!( + config["rootfs"]["diff_ids"][0], + format!("sha256:{}", dx_digest::sha256_hex(&tar)).as_str() + ); +} + +#[test] +fn oci_paths_accept_zstd_layer() { + let scratch = scratch_dir(); + let tar = oci_test_layer(false); + let zstd = ruzstd::encoding::compress_to_vec( + tar.as_slice(), + ruzstd::encoding::CompressionLevel::Fastest, + ); + let config = oci_config("amd64", &[&tar]); + let image = scratch.path().join("zstd_layer.tar"); + oci_tar( + &image, + &[ + ( + "manifest.json", + b"[{\"Config\":\"c.json\",\"Layers\":[\"l/layer.tar\"]}]", + ), + ("c.json", &config), + ("l/layer.tar", &zstd), + ], + &[], + ); + let outdir = scratch.path().join("out"); + std::fs::create_dir_all(&outdir).expect("outdir"); + oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect("zstd layer"); + let blob = outdir + .join("blobs/sha256") + .join(dx_digest::sha256_hex(&zstd)); + assert!(blob.exists() || std::fs::read_dir(&outdir).expect("outdir").next().is_some()); +} + +fn elf_header(machine: u16) -> Vec { + let mut header = vec![0u8; 64]; + header[..4].copy_from_slice(b"\x7fELF"); + header[4] = 2; + header[5] = 1; + header[6] = 1; + header[16..18].copy_from_slice(&2u16.to_le_bytes()); + header[18..20].copy_from_slice(&machine.to_le_bytes()); + header +} + +#[test] +fn oci_layout_platform_matches_the_artifact_bytes() { + let scratch = scratch_dir(); + for (platform, machine, architecture) in [ + ("linux/amd64", 0x3eu16, "amd64"), + ("linux/arm64", 0xb7u16, "arm64"), + ] { + let elf = elf_header(machine); + let tar = tar_archive([("app", elf.as_slice(), 0o755)].into_iter()).expect("tar"); + let name = format!("{architecture}.tar"); + let (image, _) = oci_save(scratch.path(), &name, &[tar], platform); + let outdir = scratch.path().join(architecture); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + platform, + ) + .expect("layout"); + let (manifest, config) = oci_layout_json(&layout); + let blob = manifest["layers"][0]["digest"].as_str().expect("digest"); + let stored = std::fs::read(layout.join("blobs").join("sha256").join(&blob[7..])) + .expect("layer blob"); + let mut archive = tar::Archive::new(stored.as_slice()); + let mut entry = archive + .entries() + .expect("entries") + .next() + .expect("entry") + .expect("entry"); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).expect("read app"); + let observed = match u16::from_le_bytes([bytes[18], bytes[19]]) { + 0x3e => "amd64", + 0xb7 => "arm64", + other => panic!("unexpected e_machine {other:#x}"), + }; + assert_eq!(config["architecture"], observed, "{platform}"); + assert_eq!(config["architecture"], architecture, "{platform}"); + assert_eq!(config["os"], "linux", "{platform}"); + } + let (arm, _) = oci_save( + scratch.path(), + "mismatch.tar", + &[oci_test_layer(false)], + "linux/arm64", + ); + let outdir = scratch.path().join("mismatch"); + std::fs::create_dir_all(&outdir).expect("outdir"); + let error = oci_build_layout( + &arm, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect_err("arm64 config staged as amd64"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("linux/arm64"), "{error}"); + assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); +} + +#[test] +fn oci_layout_rejects_missing_and_unsupported_platforms() { + let scratch = scratch_dir(); + let (image, _) = oci_save( + scratch.path(), + "oci_demo.tar", + &[oci_test_layer(false)], + "linux/amd64", + ); + let outdir = scratch.path().join("out"); + std::fs::create_dir_all(&outdir).expect("outdir"); + for platform in [ + "", + "windows/amd64", + "linux/riscv64", + "amd64", + "linux/arm64/v8", + ] { + let error = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + platform, + ) + .expect_err("unsupported platform"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{platform}"); + assert!(error.to_string().contains("platform"), "{platform}"); + } + assert!(std::fs::read_dir(&outdir).expect("outdir").next().is_none()); +} + +#[test] +fn oci_layout_media_type_follows_the_layer_bytes() { + let scratch = scratch_dir(); + for name in ["oci_demo.tar", "oci_demo.tar.gz"] { + let (image, _) = oci_save( + scratch.path(), + name, + &[oci_test_layer(false), oci_test_layer(true)], + "linux/amd64", + ); + let outdir = scratch.path().join(name.replace('.', "_")); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect("layout"); + let (manifest, _) = oci_layout_json(&layout); + assert_eq!( + manifest["layers"][0]["mediaType"], "application/vnd.oci.image.layer.v1.tar", + "{name}" + ); + assert_eq!( + manifest["layers"][1]["mediaType"], "application/vnd.oci.image.layer.v1.tar+gzip", + "{name}" + ); + } +} + +#[test] +fn oci_layout_keeps_every_docker_save_layer_and_the_config() { + let scratch = scratch_dir(); + let plain = + tar_archive([("etc/demo.conf", b"demo=1\n".as_slice(), 0o644)].into_iter()).expect("tar"); + let gz_tar = + tar_archive([("app/hello", b"hello\n".as_slice(), 0o755)].into_iter()).expect("tar"); + let gz = gzip_compress(&gz_tar).expect("gzip"); + let config = oci_config("amd64", &[&plain, &gz_tar]); + let config_hex = dx_digest::sha256_hex(&config); + let plain_hex = dx_digest::sha256_hex(&plain); + let gz_hex = dx_digest::sha256_hex(&gz); + let manifest = format!( + "[{{\"Config\":\"blobs/sha256/{config_hex}\",\"RepoTags\":[\"demo:1\"],\"Layers\":[\"blobs/sha256/{plain_hex}\",\"blobs/sha256/{gz_hex}\"]}}]" + ); + let image = scratch.path().join("image.tar"); + let config_name = format!("blobs/sha256/{config_hex}"); + let plain_name = format!("blobs/sha256/{plain_hex}"); + let gz_name = format!("blobs/sha256/{gz_hex}"); + oci_tar( + &image, + &[ + (gz_name.as_str(), &gz), + ("manifest.json", manifest.as_bytes()), + (config_name.as_str(), &config), + (plain_name.as_str(), &plain), + ], + &[], + ); + let outdir = scratch.path().join("out"); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ) + .expect("layout"); + let blobs = layout.join("blobs").join("sha256"); + let (manifest, parsed_config) = oci_layout_json(&layout); + let config_digest = manifest["config"]["digest"] + .as_str() + .expect("config digest"); + assert_eq!(config_digest, format!("sha256:{config_hex}")); + assert_eq!( + oci_declared_image_id(&image, "linux/amd64").expect("id"), + config_digest + ); + assert_eq!(manifest["config"]["size"], config.len()); + assert_eq!( + std::fs::read(blobs.join(&config_hex)).expect("config blob"), + config + ); + assert_eq!( + parsed_config["config"]["Env"], + serde_json::json!(["PATH=/usr/bin", "DEMO=1"]) + ); + assert_eq!( + parsed_config["config"]["Entrypoint"], + serde_json::json!(["/app/hello"]) + ); + let layers = manifest["layers"].as_array().expect("layers"); + assert_eq!(layers.len(), 2); + for (layer, (hex, bytes)) in layers.iter().zip([(&plain_hex, &plain), (&gz_hex, &gz)]) { + assert_eq!(layer["digest"], format!("sha256:{hex}")); + assert_eq!(layer["size"], bytes.len()); + assert_eq!(std::fs::read(blobs.join(hex)).expect("layer blob"), *bytes); + } + let mut stored: Vec = std::fs::read_dir(&blobs) + .expect("blobs") + .map(|entry| { + entry + .expect("entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + stored.sort(); + assert_eq!(stored.len(), 4, "{stored:?}"); +} + +const FAKE_SECRET: &str = "s3cret-token-value"; + +/// Runs one live push of `image` against a fake registry. +fn fake_live_push(fake: &FakeRegistry, image: &Path, outdir: &Path, platform: &str) -> i32 { + let target = registry::RegistryRef { + endpoint: registry::Endpoint::for_test(&fake.origin), + name: "demo/app".to_owned(), + }; + oci_live_push_to( + &crate::http::upload_agent_with_proxy(None), + &target, + image, + outdir, + &OciPush { + registry: "registry.invalid", + repository: "demo/app", + tag: "1.2.3", + platform, + creds: registry::Credentials { + user: "me", + secret: FAKE_SECRET, + }, + }, + ) +} + +#[test] +fn oci_live_push_pushes_the_staged_layout_digest() { + let scratch = scratch_dir(); + let (image, id) = oci_save( + scratch.path(), + "image.tar.gz", + &[oci_test_layer(false), oci_test_layer(true)], + "linux/amd64", + ); + let dry = scratch.path().join("dry"); + std::fs::create_dir_all(&dry).expect("outdir"); + let layout = oci_build_layout( + &image, + &dry, + "registry.invalid", + "demo/app", + "1.2.3", + "linux/amd64", + ) + .expect("layout"); + let (manifest, _) = oci_layout_json(&layout); + assert_eq!(manifest["config"]["digest"].as_str(), Some(id.as_str())); + let index: serde_json::Value = + serde_json::from_slice(&std::fs::read(layout.join("index.json")).expect("index")) + .expect("index json"); + let staged = index["manifests"][0]["digest"] + .as_str() + .expect("digest") + .to_owned(); + let fake = FakeRegistry::start(FakeConfig { + challenge: FakeChallenge::Bearer, + ..FakeConfig::default() + }); + let live = scratch.path().join("live"); + std::fs::create_dir_all(&live).expect("outdir"); + assert_eq!(fake_live_push(&fake, &image, &live, "linux/amd64"), 0); + let requests = fake.requests(); + let manifest_put = requests.last().expect("manifest"); + assert_eq!(manifest_put.path, "/v2/demo/app/manifests/1.2.3"); + assert_eq!(manifest_put.body_digest, staged); + let blobs: Vec<&str> = requests + .iter() + .filter(|r| r.method == "PUT" && r.path.contains("/blobs/uploads/")) + .map(|r| r.body_digest.as_str()) + .collect(); + assert!(blobs.contains(&id.as_str()), "{blobs:?}"); +} + +#[test] +fn oci_live_push_rejects_what_the_layout_rejects() { + let scratch = scratch_dir(); + let layers = [oci_test_layer(false)]; + let (arm, _) = oci_save(scratch.path(), "arm.tar", &layers, "linux/arm64"); + let (tgz, _) = oci_save(scratch.path(), "image.tgz", &layers, "linux/amd64"); + let (amd, _) = oci_save(scratch.path(), "amd.tar", &layers, "linux/amd64"); + let cases = [ + ("arm64 image", &arm, "linux/amd64"), + ("tgz suffix", &tgz, "linux/amd64"), + ("s390x platform", &amd, "linux/s390x"), + ]; + let fake = FakeRegistry::start(FakeConfig::default()); + for (name, image, platform) in cases { + let outdir = scratch.path().join(name.replace(' ', "_")); + std::fs::create_dir_all(&outdir).expect("outdir"); + let layout = oci_build_layout( + image, + &outdir, + "registry.invalid", + "demo/app", + "1.2.3", + platform, + ) + .expect_err(name); + assert_eq!( + layout.kind(), + io::ErrorKind::InvalidInput, + "{name}: {layout}" + ); + assert_eq!( + std::fs::read_dir(&outdir).expect("read outdir").count(), + 0, + "{name}" + ); + let declared = oci_declared_image_id(image, platform).expect_err(name); + if name == "arm64 image" { + assert!(layout.to_string().contains("linux/arm64"), "{layout}"); + assert!(declared.contains("linux/arm64"), "{declared}"); + } + assert_eq!(fake_live_push(&fake, image, &outdir, platform), 1, "{name}"); + assert!(fake.requests().is_empty(), "{name}"); + } + let outdir = scratch.path().join("arm_ok"); + std::fs::create_dir_all(&outdir).expect("outdir"); + assert_eq!(fake_live_push(&fake, &arm, &outdir, "linux/arm64"), 0); + assert!(!fake.requests().is_empty()); +} + +#[test] +fn oci_live_push_stops_before_manifest_on_blob_failure() { + let scratch = scratch_dir(); + let (image, _) = oci_save( + scratch.path(), + "image.tar", + &[oci_test_layer(false)], + "linux/amd64", + ); + let fake = FakeRegistry::start(FakeConfig { + fail: vec![("PUT".to_owned(), "/blobs/uploads/".to_owned(), 500)], + ..FakeConfig::default() + }); + assert_eq!( + fake_live_push(&fake, &image, scratch.path(), "linux/amd64"), + 1 + ); + let requests = fake.requests(); + assert!(requests.iter().any(|r| r.path.contains("/blobs/uploads/"))); + assert!(requests.iter().all(|r| !r.path.contains("/manifests/"))); +} + +#[test] +fn oci_live_push_output_has_no_credentials() { + const CHILD: &str = "DX_OCI_LIVE_PUSH_CHILD"; + if std::env::var_os(CHILD).is_some() { + let scratch = scratch_dir(); + let (image, _) = oci_save( + scratch.path(), + "image.tar", + &[oci_test_layer(false)], + "linux/amd64", + ); + for (challenge, secret) in [ + (FakeChallenge::Bearer, FAKE_SECRET), + (FakeChallenge::Bearer, "wrong"), + (FakeChallenge::Basic, "wrong"), + ] { + let fake = FakeRegistry::start(FakeConfig { + challenge, + secret: secret.to_owned(), + ..FakeConfig::default() + }); + let code = fake_live_push(&fake, &image, scratch.path(), "linux/amd64"); + assert_eq!(code, i32::from(secret != FAKE_SECRET)); + } + return; + } + let run = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args([ + "oci::tests::oci_live_push_output_has_no_credentials", + "--exact", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .expect("spawn child"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert!(run.status.success(), "{text}"); + assert!(text.contains("oci_deploy: pushed registry.invalid/demo/app:1.2.3@sha256:")); + assert!(text.contains(registry::OCI_BAD_CREDENTIALS), "{text}"); + let basic = crate::http::basic_auth("me", FAKE_SECRET); + for secret in [FAKE_SECRET, FAKE_TOKEN, basic.as_str(), &basic[6..]] { + assert!(!text.contains(secret), "{text}"); + } +} + +#[test] +fn oci_paths_reject_rootfs_and_inconsistent_archives() { + let scratch = scratch_dir(); + let layer = oci_test_layer(false); + let other = tar_archive([("other", b"other\n".as_slice(), 0o644)].into_iter()).expect("tar"); + let config = oci_config("amd64", &[&layer]); + let twice = oci_config("amd64", &[&layer, &layer]); + let both = |name: &str| { + let image = scratch.path().join(name); + let outdir = scratch.path().join(name.replace('.', "_")); + std::fs::create_dir_all(&outdir).expect("outdir"); + let id = oci_declared_image_id(&image, "linux/amd64"); + let layout = oci_build_layout( + &image, + &outdir, + "registry.invalid", + "demo/app", + "1.0.0", + "linux/amd64", + ); + (id, layout, outdir) + }; + let manifest_one = b"[{\"Config\":\"c.json\",\"Layers\":[\"a/layer.tar\"]}]"; + let manifest_two = b"[{\"Config\":\"c.json\",\"Layers\":[\"a/layer.tar\",\"b/layer.tar\"]}]"; + std::fs::write(scratch.path().join("rootfs.tar"), &layer).expect("rootfs"); + oci_tar( + &scratch.path().join("missing.tar"), + &[("manifest.json", manifest_one), ("c.json", &config)], + &[], + ); + oci_tar( + &scratch.path().join("mismatch.tar"), + &[ + ("manifest.json", manifest_one), + ("c.json", &config), + ("a/layer.tar", &other), + ], + &[], + ); + oci_tar( + &scratch.path().join("escape.tar"), + &[("manifest.json", manifest_one), ("c.json", &config)], + &[("a/layer.tar", "../../etc/passwd")], + ); + for (name, want) in [ + ("rootfs.tar", "no manifest.json"), + ("missing.tar", "missing its layer 'a/layer.tar'"), + ("mismatch.tar", "diff_ids do not match"), + ("escape.tar", "escapes the archive"), + ] { + let (id, layout, outdir) = both(name); + let id = id.expect_err(name); + assert!(id.contains(want), "{name}: {id}"); + let error = layout.expect_err(name); + assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{name}"); + assert!(error.to_string().contains(want), "{name}: {error}"); + assert!( + std::fs::read_dir(&outdir).expect("outdir").next().is_none(), + "{name}" + ); + } + let id_name = dx_digest::sha256_hex(&config); + let legacy_manifest = + format!("[{{\"Config\":\"{id_name}.json\",\"Layers\":[\"{id_name}/layer.tar\"]}}]"); + let legacy_config = format!("{id_name}.json"); + let legacy_layer = format!("{id_name}/layer.tar"); + oci_tar( + &scratch.path().join("legacy.tar"), + &[ + ("manifest.json", legacy_manifest.as_bytes()), + (legacy_config.as_str(), &config), + (legacy_layer.as_str(), &layer), + ], + &[], + ); + let blob_manifest = + format!("[{{\"Config\":\"blobs/sha256/{id_name}\",\"Layers\":[\"./a/layer.tar\"]}}]"); + let blob_config = format!("blobs/sha256/{id_name}"); + oci_tar( + &scratch.path().join("blobs.tar"), + &[ + ("manifest.json", blob_manifest.as_bytes()), + (blob_config.as_str(), &config), + ("a/layer.tar", &layer), + ], + &[], + ); + oci_tar( + &scratch.path().join("linked.tar"), + &[ + ("manifest.json", manifest_two), + ("c.json", &twice), + ("a/layer.tar", &layer), + ], + &[("b/layer.tar", "../a/layer.tar")], + ); + for (name, body) in [ + ("legacy.tar", &config), + ("blobs.tar", &config), + ("linked.tar", &twice), + ] { + let want = format!("sha256:{}", dx_digest::sha256_hex(body)); + let (id, layout, _) = both(name); + assert_eq!(id.expect(name), want, "{name}"); + let (manifest, _) = oci_layout_json(&layout.expect(name)); + assert_eq!(manifest["config"]["digest"], want.as_str(), "{name}"); + } +} + +#[test] +fn archive_image_writes_a_deterministic_docker_save_archive() { + let scratch = scratch_dir(); + let plain = scratch.path().join("plain.tar"); + let gz = scratch.path().join("gz.tar.gz"); + std::fs::write(&plain, oci_test_layer(false)).expect("plain"); + std::fs::write(&gz, oci_test_layer(true)).expect("gz"); + let first = scratch.path().join("first.tar"); + let second = scratch.path().join("second.tar"); + let layers = [plain.as_path(), gz.as_path()]; + let id = archive_image(&layers, "linux/arm64", &["/app/hello"], &first).expect("first"); + let again = archive_image(&layers, "linux/arm64", &["/app/hello"], &second).expect("again"); + assert_eq!(id, again); + assert_eq!( + std::fs::read(&first).expect("first"), + std::fs::read(&second).expect("second") + ); + let archive = oci_read_archive(&first, None, None).expect("read"); + assert_eq!(archive.config_digest, id); + let config: serde_json::Value = serde_json::from_slice(&archive.config).expect("config"); + assert_eq!(config["architecture"], "arm64"); + assert_eq!(config["os"], "linux"); + assert_eq!(config["created"], "1970-01-01T00:00:00Z"); + assert_eq!( + config["config"]["Entrypoint"], + serde_json::json!(["/app/hello"]) + ); + let tar_digest = format!("sha256:{}", dx_digest::sha256_hex(&oci_test_layer(false))); + assert_eq!( + config["rootfs"]["diff_ids"], + serde_json::json!([tar_digest, tar_digest]) + ); + assert_eq!(archive.layers.len(), 2); + assert_eq!(archive.layers[0].digest, tar_digest); + assert_ne!(archive.layers[1].digest, tar_digest); + let out = scratch.path().join("bad.tar"); + for (layers, platform) in [ + (&[][..], "linux/amd64"), + (&layers[..], "windows/amd64"), + (&layers[..], ""), + ] { + let error = archive_image(layers, platform, &[], &out).expect_err(platform); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{platform}"); + } + assert!(!out.exists()); + let good = scratch.path().join("good.tar.zst"); + let plain_bytes = std::fs::read(&plain).expect("plain"); + std::fs::write( + &good, + ruzstd::encoding::compress_to_vec( + plain_bytes.as_slice(), + ruzstd::encoding::CompressionLevel::Fastest, + ), + ) + .expect("good zst"); + archive_image(&[good.as_path()], "linux/amd64", &[], &out).expect("valid zstd layer"); + std::fs::remove_file(&out).expect("remove"); + let zst = scratch.path().join("zst.tar.zst"); + let mut zstd = ZSTD_MAGIC.to_vec(); + zstd.extend_from_slice(b"not a real zstd frame"); + std::fs::write(&zst, zstd).expect("zst"); + let error = archive_image(&[plain.as_path(), zst.as_path()], "linux/amd64", &[], &out) + .expect_err("zstd"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(error.to_string().contains("is not valid zstd"), "{error}"); + assert!(!out.exists()); +} From f3100014298b85eb316d72221e9126a2aa117c26 Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Tue, 10 Nov 2026 00:34:56 +0100 Subject: [PATCH 5/5] dx: place the moved test files in dx_cli_test srcs (part of #10053) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hzLvRXZcoZvVp18RGd --- cli/cli/BUILD.bazel | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/cli/BUILD.bazel b/cli/cli/BUILD.bazel index 2c73a13d7b..3ffb224274 100644 --- a/cli/cli/BUILD.bazel +++ b/cli/cli/BUILD.bazel @@ -27,7 +27,6 @@ rust_library( "src/exec.rs", "src/exec/affected.rs", "src/exec/audit.rs", - "src/exec/audit_tests.rs", "src/exec/audit_accept.rs", "src/exec/audit_cyclonedx.rs", "src/exec/audit_images.rs", @@ -42,7 +41,6 @@ rust_library( "src/exec/changed_coverage.rs", "src/exec/clean.rs", "src/exec/common.rs", - "src/exec/common_tests.rs", "src/exec/compiler_stderr.rs", "src/exec/coverage_candidate.rs", "src/exec/dep_edit.rs", @@ -86,18 +84,15 @@ rust_library( "src/exec/test_reports/collect.rs", "src/exec/test_reports/report.rs", "src/exec/umbrella.rs", - "src/exec/umbrella_tests.rs", "src/exec/update.rs", "src/exec/update_add_missing.rs", "src/exec/update_advisory.rs", "src/exec/update_consumer.rs", - "src/exec/update_consumer_tests.rs", "src/exec/update_outdated.rs", "src/exec/update_pr.rs", "src/exec/update_prune.rs", "src/exec/update_unused.rs", "src/exec/update_version.rs", - "src/exec/update_version_tests.rs", "src/exec/update_version_add.rs", "src/exec/workflow.rs", "src/exec/worktree.rs", @@ -266,6 +261,7 @@ rust_test( "src/exec.rs", "src/exec/affected.rs", "src/exec/audit.rs", + "src/exec/audit_tests.rs", "src/exec/audit_accept.rs", "src/exec/audit_consumer.rs", "src/exec/audit_cyclonedx.rs", @@ -286,6 +282,7 @@ rust_test( "src/exec/changed_coverage_tests.rs", "src/exec/clean.rs", "src/exec/common.rs", + "src/exec/common_tests.rs", "src/exec/compiler_stderr.rs", "src/exec/coverage_candidate.rs", "src/exec/current_dir_guard_tests.rs", @@ -350,17 +347,20 @@ rust_test( "src/exec/test_support.rs", "src/exec/text_output_tests.rs", "src/exec/umbrella.rs", + "src/exec/umbrella_tests.rs", "src/exec/update.rs", "src/exec/update_add_missing.rs", "src/exec/update_advisory.rs", "src/exec/update_check.rs", "src/exec/update_consumer.rs", + "src/exec/update_consumer_tests.rs", "src/exec/update_live.rs", "src/exec/update_outdated.rs", "src/exec/update_pr.rs", "src/exec/update_prune.rs", "src/exec/update_unused.rs", "src/exec/update_version.rs", + "src/exec/update_version_tests.rs", "src/exec/update_version_add.rs", "src/exec/update_version_add_tests.rs", "src/exec/workflow.rs",