1use std::{
24 collections::{BTreeMap, BTreeSet},
25 ffi::OsString,
26 fs,
27 io::Write,
28 path::{Path, PathBuf},
29 time::Instant,
30};
31
32use serde::{Deserialize, Serialize};
33
34use crate::{
35 evidence_archive::write_archive,
36 frontend_protocol::validate_frontend_report_request,
37 integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
38 jvm_project::{
39 JvmBuild, PreparedJvmProject, detect_build, jvm_integrity_inputs, prepare_jvm_project,
40 },
41 lifecycle::{
42 ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
43 remove_stored_tree_deferred,
44 },
45 orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
46 owned_evidence::{
47 OwnedRunInputs, OwnedTestOutcome, build_frontend_run, jvm_coverage_model, jvm_declaration,
48 merge_evidence, read_evidence,
49 },
50 process_supervision::{CommandSpec, SupervisionOptions},
51 run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
52 workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
53};
54
55const RUNTIME_SOURCE: &str =
56 include_str!("../runtime-assets/jvm/com/supercorp/supercov/Supercov.java");
57const LISTENER_SOURCE: &str =
58 include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovListener.java");
59
60const TESTNG_LISTENER_SOURCE: &str =
61 include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovTestNGListener.java");
62
63const PACKAGE_DIRECTORY: &str = "com/supercorp/supercov";
64
65const SERVICES_FILE: &str = "META-INF/services/org.junit.platform.launcher.TestExecutionListener";
67
68const LISTENER_CLASS: &str = "com.supercorp.supercov.SupercovListener";
69
70const TESTNG_SERVICES_FILE: &str = "META-INF/services/org.testng.ITestNGListener";
72
73const TESTNG_LISTENER_CLASS: &str = "com.supercorp.supercov.SupercovTestNGListener";
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83struct Frameworks {
84 platform: bool,
85 testng: bool,
86 junit4: bool,
89}
90
91fn version_catalog(workspace: &Path) -> BTreeMap<String, String> {
105 let mut resolved = BTreeMap::new();
106 let Ok(text) = fs::read_to_string(workspace.join("gradle/libs.versions.toml")) else {
107 return resolved;
108 };
109 let Ok(catalog) = text.parse::<toml::Table>() else {
110 return resolved;
111 };
112 let accessor = |alias: &str| alias.replace(['-', '_'], ".");
113 if let Some(libraries) = catalog.get("libraries").and_then(toml::Value::as_table) {
114 for (alias, value) in libraries {
115 let coordinates = match value {
116 toml::Value::String(coordinates) => coordinates.clone(),
117 toml::Value::Table(table) => {
118 match table.get("module").and_then(toml::Value::as_str) {
119 Some(module) => module.to_owned(),
120 None => match (
121 table.get("group").and_then(toml::Value::as_str),
122 table.get("name").and_then(toml::Value::as_str),
123 ) {
124 (Some(group), Some(name)) => format!("{group}:{name}"),
125 _ => continue,
126 },
127 }
128 }
129 _ => continue,
130 };
131 resolved.insert(accessor(alias), format!("\"{coordinates}\""));
132 }
133 }
134 if let Some(bundles) = catalog.get("bundles").and_then(toml::Value::as_table) {
137 let libraries = resolved.clone();
138 for (alias, value) in bundles {
139 let Some(members) = value.as_array() else {
140 continue;
141 };
142 let expanded = members
143 .iter()
144 .filter_map(toml::Value::as_str)
145 .filter_map(|member| libraries.get(&accessor(member)).cloned())
146 .collect::<Vec<_>>()
147 .join(" ");
148 resolved.insert(format!("bundles.{}", accessor(alias)), expanded);
149 }
150 }
151 resolved
152}
153
154fn with_catalog(text: &str, catalog: &BTreeMap<String, String>) -> String {
156 let mut out = text.to_owned();
157 for (accessor, coordinates) in catalog {
158 let needle = format!("libs.{accessor}");
160 let named = text.match_indices(&needle).any(|(at, _)| {
161 text[at + needle.len()..]
162 .chars()
163 .next()
164 .is_none_or(|next| !next.is_alphanumeric() && !matches!(next, '.' | '_' | '-'))
165 });
166 if named {
167 out.push('\n');
168 out.push_str(coordinates);
169 }
170 }
171 out
172}
173
174fn frameworks(build_file: &str) -> Frameworks {
175 let testng = build_file.contains("testng");
176 let platform = [
178 "junit-jupiter",
179 "junit-platform",
180 "junit-vintage",
181 "kotest",
182 "spock",
183 ]
184 .iter()
185 .any(|name| build_file.contains(name));
186 let junit4 = !platform
192 && (build_file.contains("<groupId>junit</groupId>") || build_file.contains("'junit:junit"))
193 || build_file.contains("\"junit:junit");
194 Frameworks {
195 platform: platform || !(testng || junit4),
198 testng,
199 junit4: junit4 && !platform,
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct DirectJvmRunRequest {
206 pub root: PathBuf,
207 pub command: Vec<String>,
208 pub run_id: String,
209 pub started_at: String,
210}
211
212#[derive(Debug, Clone, PartialEq)]
213pub struct DirectJvmRunResult {
214 pub run_id: String,
215 pub run_directory: PathBuf,
216 pub exit_code: i32,
217 pub tests: usize,
218 pub source_files: usize,
219 pub modules: usize,
220 pub build: JvmBuild,
221 pub recovered_runs: Vec<String>,
222 pub metadata: RunMetadata,
223}
224
225fn elapsed_ms(started: Instant) -> f64 {
226 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
227}
228
229fn write(path: &Path, contents: &str) -> Result<(), String> {
230 if let Some(parent) = path.parent() {
231 fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
232 }
233 fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
234}
235
236fn source_root(source_set: &str) -> String {
242 format!("src/{source_set}/java")
243}
244
245fn java_literal(value: &str) -> String {
248 let mut out = String::with_capacity(value.len() + 2);
249 out.push('"');
250 for character in value.chars() {
251 match character {
252 '"' => out.push_str("\\\""),
253 '\\' => out.push_str("\\\\"),
254 '\n' => out.push_str("\\n"),
255 '\r' => out.push_str("\\r"),
256 other => out.push(other),
257 }
258 }
259 out.push('"');
260 out
261}
262
263fn runtime_source(probe_count: usize) -> String {
273 let marker = "static final int PROBE_COUNT = 0; // supercov:probe-count";
274 debug_assert!(
275 RUNTIME_SOURCE.contains(marker),
276 "the runtime no longer declares the probe count Supercov substitutes"
277 );
278 RUNTIME_SOURCE.replace(
279 marker,
280 &format!("static final int PROBE_COUNT = {probe_count}; // supercov:probe-count"),
281 )
282}
283
284fn configuration(probe_count: usize, widths: &[u8], evidence: &Path) -> String {
286 let widths = widths
287 .iter()
288 .map(u8::to_string)
289 .collect::<Vec<_>>()
290 .join(", ");
291 format!(
292 "// Code generated by Supercov. DO NOT EDIT.\npackage com.supercorp.supercov;\n\npublic final class SupercovConfig {{\n private SupercovConfig() {{}}\n\n public static final int PROBES = {probe_count};\n public static final int[] WIDTHS = new int[] {{{widths}}};\n public static final String EVIDENCE = {};\n}}\n",
293 java_literal(&evidence.to_string_lossy())
294 )
295}
296
297fn sequential_properties(existing: Option<&str>) -> String {
304 let mut out = String::new();
305 for line in existing.unwrap_or("").lines() {
306 if line
307 .trim_start()
308 .starts_with("junit.jupiter.execution.parallel.enabled")
309 {
310 continue;
311 }
312 out.push_str(line);
313 out.push('\n');
314 }
315 out.push_str(
316 "# Set by Supercov: probes are a store into one shared array, so two tests running\n\
317 # at once cannot both be credited with what they reached.\n\
318 junit.jupiter.execution.parallel.enabled=false\n",
319 );
320 out
321}
322
323const LAUNCHER_ARTIFACT: &str = "junit-platform-launcher";
332
333fn launcher_version(build_file: &str) -> Option<String> {
341 if build_file.contains("junit-bom") {
347 return None;
348 }
349 Some(jupiter_version(build_file).unwrap_or_else(|| DEFAULT_LAUNCHER_VERSION.to_owned()))
350}
351
352fn jupiter_version(build_file: &str) -> Option<String> {
355 let jupiter = build_file.find("junit-jupiter")?;
356 let rest = &build_file[jupiter..];
357 rest.match_indices("5.").find_map(|(at, _)| {
358 let tail = &rest[at + 2..];
359 let minor = tail
360 .chars()
361 .take_while(char::is_ascii_digit)
362 .collect::<String>();
363 let patch = tail[minor.len()..]
364 .strip_prefix('.')?
365 .chars()
366 .take_while(char::is_ascii_digit)
367 .collect::<String>();
368 (!minor.is_empty() && !patch.is_empty()).then(|| format!("1.{minor}.{patch}"))
369 })
370}
371
372const DEFAULT_LAUNCHER_VERSION: &str = "1.10.2";
373
374fn engine_version_of_platform(platform: &str) -> String {
376 match platform.strip_prefix("1.") {
377 Some(rest) => format!("5.{rest}"),
378 None => platform.to_owned(),
379 }
380}
381
382fn maven_with_launcher(pom: &str) -> Option<String> {
384 maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT])
385}
386
387const VINTAGE_ARTIFACT: &str = "junit-vintage-engine";
395
396fn maven_with_vintage(pom: &str) -> Option<String> {
397 maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT, VINTAGE_ARTIFACT])
398}
399
400fn maven_with_test_artifacts(pom: &str, artifacts: &[&str]) -> Option<String> {
401 let missing = artifacts
402 .iter()
403 .filter(|artifact| !pom.contains(**artifact))
404 .collect::<Vec<_>>();
405 if missing.is_empty() {
406 return None;
407 }
408 let platform = launcher_version(pom);
409 let dependency = missing
410 .iter()
411 .map(|artifact| {
412 let (group, version) = if **artifact == VINTAGE_ARTIFACT {
416 (
417 "org.junit.vintage",
418 platform.as_deref().map(engine_version_of_platform),
419 )
420 } else {
421 ("org.junit.platform", platform.clone())
422 };
423 let version = version
424 .map(|version| format!("\n <version>{version}</version>"))
425 .unwrap_or_default();
426 format!(
427 " <dependency>\n <groupId>{group}</groupId>\n <artifactId>{artifact}</artifactId>{version}\n <scope>test</scope>\n </dependency>\n"
428 )
429 })
430 .collect::<String>();
431 match project_dependencies_end(pom) {
432 Some(at) => Some(format!("{}{dependency}{}", &pom[..at], &pom[at..])),
433 None => pom.rfind("</project>").map(|at| {
435 format!(
436 "{} <dependencies>\n{dependency} </dependencies>\n{}",
437 &pom[..at],
438 &pom[at..]
439 )
440 }),
441 }
442}
443
444fn project_dependencies_end(pom: &str) -> Option<usize> {
453 const NESTED: [&str; 4] = ["dependencyManagement", "profiles", "build", "reporting"];
454 let bytes = pom.as_bytes();
455 let mut depth = 0usize;
456 let mut at = 0usize;
457 while at < bytes.len() {
458 let Some(open) = pom[at..].find('<') else {
459 break;
460 };
461 let start = at + open;
462 let Some(close) = pom[start..].find('>') else {
463 break;
464 };
465 let tag = &pom[start + 1..start + close];
466 at = start + close + 1;
467 let name = tag.trim_start_matches('/').trim_end_matches('/').trim();
468 let name = name.split_whitespace().next().unwrap_or_default();
469 if NESTED.contains(&name) {
470 if tag.starts_with('/') {
471 depth = depth.saturating_sub(1);
472 } else if !tag.ends_with('/') {
473 depth += 1;
474 }
475 } else if name == "dependencies" && tag.starts_with('/') && depth == 0 {
476 return Some(start);
477 }
478 }
479 None
480}
481
482fn declares_launcher_for_compilation(build_file: &str) -> bool {
492 build_file.lines().any(|line| {
493 line.contains(LAUNCHER_ARTIFACT)
494 && ["testImplementation", "testCompileOnly", "testApi"]
495 .iter()
496 .any(|configuration| line.contains(configuration))
497 })
498}
499
500fn gradle_with_launcher(build_file: &str, kotlin: bool) -> Option<String> {
507 if declares_launcher_for_compilation(build_file) {
508 return None;
509 }
510 let coordinate = match launcher_version(build_file) {
511 Some(version) => format!("org.junit.platform:{LAUNCHER_ARTIFACT}:{version}"),
512 None => format!("org.junit.platform:{LAUNCHER_ARTIFACT}"),
513 };
514 let line = if kotlin {
522 format!(" \"testImplementation\"(\"{coordinate}\")")
523 } else {
524 format!(" testImplementation '{coordinate}'")
525 };
526 let plugin = if kotlin { "\"java\"" } else { "'java'" };
527 Some(format!(
528 "{build_file}\n// Added by Supercov: the JUnit Platform listener that attributes coverage\n// to each test is compiled from each project's own test sources, and the\n// launcher API it implements is on the test runtime classpath but not the\n// compile one.\nallprojects {{\n plugins.withId({plugin}) {{\n dependencies {{\n{line}\n }}\n }}\n}}\n"
529 ))
530}
531
532fn without_warnings_as_errors(build_file: &str) -> Option<String> {
545 let mut updated = build_file.to_owned();
546 for (from, to) in [
547 (
548 "<failOnWarning>true</failOnWarning>",
549 "<failOnWarning>false</failOnWarning>",
550 ),
551 (
552 "<failOnWarnings>true</failOnWarnings>",
553 "<failOnWarnings>false</failOnWarnings>",
554 ),
555 ("<arg>-Werror</arg>", ""),
556 ("<compilerArgument>-Werror</compilerArgument>", ""),
557 ("options.compilerArgs << '-Werror'", ""),
558 ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
559 ] {
560 updated = updated.replace(from, to);
561 }
562 updated = without_error_prone(&updated);
563 (updated != build_file).then_some(updated)
564}
565
566fn without_error_prone(build_file: &str) -> String {
578 let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
579 return build_file.to_owned();
580 };
581 let Some(end) = build_file[start..].find("</arg>") else {
582 return build_file.to_owned();
583 };
584 let mut updated = build_file.to_owned();
585 updated.replace_range(start..start + end + "</arg>".len(), "");
586 updated
587}
588
589fn command_with_fresh_results(
594 build: JvmBuild,
595 command: &[String],
596) -> (Vec<String>, Option<String>) {
597 let mut updated = command.to_vec();
598 match build {
599 JvmBuild::Gradle => {
600 if updated
601 .iter()
602 .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
603 {
604 return (updated, None);
605 }
606 updated.push("--rerun-tasks".into());
607 (
608 updated,
609 Some(
610 "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
611 .into(),
612 ),
613 )
614 }
615 JvmBuild::Maven | JvmBuild::Plain => (updated, None),
618 }
619}
620
621#[derive(Debug, Clone)]
630struct JvmModule {
631 directory: String,
633 evidence: PathBuf,
637 has_tests: bool,
638}
639
640struct InstrumentedWorkspace {
641 project: PreparedJvmProject,
642 build: JvmBuild,
643 modules: Vec<JvmModule>,
644 declared_in: BTreeMap<String, String>,
646 added_launcher: Option<&'static str>,
648 added_vintage: bool,
651 relaxed: Vec<&'static str>,
654 unmeasurable: Vec<String>,
657 modular: Vec<String>,
659}
660
661fn module_of(relative: &str) -> String {
669 match relative.find("src/") {
670 Some(0) | None => ".".to_owned(),
671 Some(at) => relative[..at].trim_end_matches('/').to_owned(),
672 }
673}
674
675fn class_of(test_name: &str) -> Option<&str> {
678 test_name.split('#').next().filter(|name| !name.is_empty())
679}
680
681fn instrument_workspace(
682 workspace: &Path,
683 evidence_directory: &Path,
684) -> Result<InstrumentedWorkspace, String> {
685 let project = prepare_jvm_project(workspace)?;
686 let build = detect_build(workspace);
687
688 for (relative, instrumented) in &project.instrumented {
689 write(&workspace.join(relative), instrumented)?;
690 }
691
692 let probe_count = project
693 .probes
694 .keys()
695 .max()
696 .map_or(0, |highest| *highest as usize + 1);
697
698 let catalog = version_catalog(workspace);
704 let frameworks_of = |directory: &str| -> Frameworks {
705 let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
706 let mut text = names
707 .iter()
708 .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
709 .unwrap_or_default();
710 for name in names {
711 if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
712 text.push('\n');
713 text.push_str(&own);
714 break;
715 }
716 }
717 frameworks(&with_catalog(&text, &catalog))
718 };
719 let mut unmeasurable: Vec<String> = Vec::new();
720 let mut modular: Vec<String> = Vec::new();
721
722 let mut modules: BTreeMap<String, bool> = BTreeMap::new();
725 for (relative, _) in &project.files.sources {
726 modules.entry(module_of(relative)).or_insert(false);
727 }
728 for (relative, _) in &project.files.tests {
729 *modules.entry(module_of(relative)).or_default() = true;
730 }
731 for module in modules.keys().cloned().collect::<Vec<_>>() {
738 if workspace.join(&module).join("src/test").is_dir() {
739 modules.insert(module, true);
740 }
741 }
742 if modules.is_empty() {
743 modules.insert(".".to_owned(), true);
744 }
745
746 let modules = modules
747 .into_iter()
748 .map(|(directory, has_tests)| JvmModule {
749 evidence: evidence_directory.join(
750 directory
751 .chars()
752 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
753 .collect::<String>(),
754 ),
755 directory,
756 has_tests,
757 })
758 .collect::<Vec<_>>();
759
760 for module in &modules {
761 let frameworks = frameworks_of(&module.directory);
762 let at = |source_set: &str| {
763 workspace
764 .join(&module.directory)
765 .join(source_root(source_set))
766 .join(PACKAGE_DIRECTORY)
767 };
768 write(
773 &at("main").join("Supercov.java"),
774 &runtime_source(probe_count),
775 )?;
776 if !module.has_tests {
777 continue;
778 }
779 if frameworks.junit4 && build != JvmBuild::Maven {
780 unmeasurable.push(module.directory.clone());
784 continue;
785 }
786 if workspace
796 .join(&module.directory)
797 .join(source_root("test"))
798 .join("module-info.java")
799 .exists()
800 {
801 modular.push(module.directory.clone());
802 continue;
803 }
804 let test = at("test");
807 write(
808 &test.join("SupercovConfig.java"),
809 &configuration(probe_count, &project.decision_widths, &module.evidence),
810 )?;
811 if frameworks.platform || frameworks.junit4 {
812 write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
813 }
814 if frameworks.testng {
815 write(
816 &test.join("SupercovTestNGListener.java"),
817 TESTNG_LISTENER_SOURCE,
818 )?;
819 }
820 }
821
822 let mut relaxed: Vec<&'static str> = Vec::new();
825 for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
826 let path = workspace.join(name);
827 let Ok(existing) = fs::read_to_string(&path) else {
828 continue;
829 };
830 let Some(updated) = without_warnings_as_errors(&existing) else {
831 continue;
832 };
833 if !relaxed.contains(&"stopped the build failing on warnings")
837 && updated.contains("<failOnWarning>false</failOnWarning>")
838 != existing.contains("<failOnWarning>false</failOnWarning>")
839 || existing.contains("-Werror") && !updated.contains("-Werror")
840 {
841 relaxed.push("stopped the build failing on warnings");
842 }
843 if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
844 relaxed.push("switched Error Prone off");
845 }
846 write(&path, &updated)?;
847 }
848 relaxed.dedup();
849
850 let mut added_launcher = None;
855 let mut added_vintage = false;
856 match build {
857 JvmBuild::Maven => {
861 for module in modules
862 .iter()
863 .filter(|module| {
864 module.has_tests
865 && !unmeasurable.contains(&module.directory)
866 && !modular.contains(&module.directory)
867 })
868 .filter(|module| {
872 let frameworks = frameworks_of(&module.directory);
873 frameworks.platform || frameworks.junit4
874 })
875 {
876 let pom = workspace.join(&module.directory).join("pom.xml");
877 let Ok(existing) = fs::read_to_string(&pom) else {
878 continue;
879 };
880 let updated = if frameworks_of(&module.directory).junit4 {
884 added_vintage = true;
885 maven_with_vintage(&existing)
886 } else {
887 maven_with_launcher(&existing)
888 };
889 if let Some(updated) = updated {
890 write(&pom, &updated)?;
891 added_launcher = Some("pom.xml");
892 }
893 }
894 }
895 JvmBuild::Gradle
896 if !modules
897 .iter()
898 .any(|module| module.has_tests && frameworks_of(&module.directory).platform) => {}
899 JvmBuild::Gradle => {
900 for name in ["build.gradle.kts", "build.gradle"] {
901 let path = workspace.join(name);
902 let Ok(existing) = fs::read_to_string(&path) else {
903 continue;
904 };
905 if let Some(updated) = gradle_with_launcher(&existing, name.ends_with(".kts")) {
906 write(&path, &updated)?;
907 added_launcher = Some(if name.ends_with(".kts") {
908 "build.gradle.kts"
909 } else {
910 "build.gradle"
911 });
912 }
913 break;
914 }
915 }
916 JvmBuild::Plain => {}
919 }
920
921 for module in modules.iter().filter(|module| {
922 module.has_tests
923 && !unmeasurable.contains(&module.directory)
924 && !modular.contains(&module.directory)
925 }) {
926 let frameworks = frameworks_of(&module.directory);
927 let resources = workspace.join(&module.directory).join("src/test/resources");
928 if frameworks.platform || frameworks.junit4 {
929 write(
930 &resources.join(SERVICES_FILE),
931 &format!("{LISTENER_CLASS}\n"),
932 )?;
933 let properties = resources.join("junit-platform.properties");
934 let existing = fs::read_to_string(&properties).ok();
935 write(&properties, &sequential_properties(existing.as_deref()))?;
936 }
937 if frameworks.testng {
938 write(
939 &resources.join(TESTNG_SERVICES_FILE),
940 &format!("{TESTNG_LISTENER_CLASS}\n"),
941 )?;
942 }
943 }
944
945 let mut declared_in = BTreeMap::new();
949 for (relative, _) in &project.files.tests {
950 if let Some(stem) = relative
951 .rsplit('/')
952 .next()
953 .and_then(|name| name.split('.').next())
954 {
955 declared_in.insert(stem.to_owned(), relative.clone());
956 }
957 }
958
959 Ok(InstrumentedWorkspace {
960 project,
961 build,
962 modules,
963 declared_in,
964 added_launcher,
965 added_vintage,
966 relaxed,
967 unmeasurable,
968 modular,
969 })
970}
971
972pub fn current_jvm_integrity(
974 root: &Path,
975 command: &[String],
976) -> Result<crate::run_store::RunIntegrity, String> {
977 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
978 let files = crate::jvm_project::discover_jvm_files(&root)?;
979 create_explicit_run_integrity(
980 &root,
981 &jvm_integrity_inputs(&files, command),
982 &FrontendIntegrityInputs::embedded_jvm(),
983 )
984 .map_err(|error| error.to_string())
985}
986
987pub fn run_direct_jvm(
988 request: &DirectJvmRunRequest,
989 diagnostics: &mut dyn Write,
990) -> Result<DirectJvmRunResult, String> {
991 if request.command.is_empty() {
992 return Err("test command must not be empty".into());
993 }
994 let total_started = Instant::now();
995 let initialization_started = Instant::now();
996 let root = canonicalize_simplified(&request.root)
997 .map_err(|error| format!("{}: {error}", request.root.display()))?;
998 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
999 .map_err(|error| error.to_string())?;
1000 let initialization_ms = elapsed_ms(initialization_started);
1001 let work_directory = root.join(".supercov/work").join(&request.run_id);
1002 let result = (|| {
1003 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1004 .map_err(|error| error.to_string())?;
1005 if !recovered_runs.is_empty() {
1006 writeln!(
1007 diagnostics,
1008 "[supercov] recovered abandoned run(s): {}",
1009 recovered_runs.join(", ")
1010 )
1011 .map_err(|error| error.to_string())?;
1012 }
1013
1014 let adapter_started = Instant::now();
1015 let files = crate::jvm_project::discover_jvm_files(&root)?;
1016 let integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1017 let assertion_inputs =
1018 crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1019 let integrity = create_explicit_run_integrity(
1020 &root,
1021 &integrity_inputs,
1022 &FrontendIntegrityInputs::embedded_jvm(),
1023 )
1024 .map_err(|error| error.to_string())?;
1025
1026 let workspace_started = Instant::now();
1027 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1028 let workspace =
1029 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1030 let evidence_directory = work_directory.join("jvm");
1031 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1032 let instrumented = instrument_workspace(&workspace, &evidence_directory)?;
1033 let workspace_preparation_ms = elapsed_ms(workspace_started);
1034 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1035 writeln!(
1036 diagnostics,
1037 "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1038 match instrumented.build {
1039 JvmBuild::Maven => "a Maven project",
1040 JvmBuild::Gradle => "a Gradle project",
1041 JvmBuild::Plain => "Java/Kotlin sources",
1042 },
1043 instrumented.project.instrumented.len(),
1044 workspace.display()
1045 )
1046 .map_err(|error| error.to_string())?;
1047 if let Some(build_file) = instrumented.added_launcher {
1048 writeln!(
1049 diagnostics,
1050 "[supercov] added a test-scoped {LAUNCHER_ARTIFACT} to the workspace's {build_file}: per-test attribution comes from a JUnit Platform listener, and the API it implements is on the test runtime classpath but not the compile one. Your own {build_file} is untouched."
1051 )
1052 .map_err(|error| error.to_string())?;
1053 }
1054 if !instrumented.relaxed.is_empty() {
1055 writeln!(
1056 diagnostics,
1057 "[supercov] in the workspace copy only: {}. The copy holds instrumented code your project never wrote a policy for, and a rule about the shape of a method is one no instrumentation can satisfy. Warnings are still reported, your build file is untouched, and your own build still runs every check in full.",
1058 instrumented.relaxed.join("; ")
1059 )
1060 .map_err(|error| error.to_string())?;
1061 }
1062 if instrumented.added_vintage {
1063 writeln!(
1064 diagnostics,
1065 "[supercov] added junit-vintage-engine to the workspace copy: JUnit 4 is not a JUnit Platform engine, and Vintage is the platform's own way of running exactly these tests through the lifecycle Supercov listens to. Your own build still runs JUnit 4 as it did."
1066 )
1067 .map_err(|error| error.to_string())?;
1068 }
1069 if !instrumented.modular.is_empty() {
1070 writeln!(
1071 diagnostics,
1072 "[supercov] {} module(s) declare their tests as a Java module and are not attributed: {}. A named module names every package it holds and every dependency it may use, in its own module-info.java, so a listener added to it would not compile -- and neither would the module. Supercov leaves those tests to run exactly as they did.",
1073 instrumented.modular.len(),
1074 instrumented.modular.join(", ")
1075 )
1076 .map_err(|error| error.to_string())?;
1077 }
1078 if !instrumented.unmeasurable.is_empty() {
1079 writeln!(
1080 diagnostics,
1081 "[supercov] {} module(s) run JUnit 4, which is not a JUnit Platform engine, so they are not attributed: {}. Supercov listens through the platform's own lifecycle, and putting the platform on a JUnit 4 classpath makes the build choose a provider that finds no engine -- so it leaves those modules alone rather than break them. Adding junit-vintage-engine runs the same tests on the platform, and Supercov measures them.",
1082 instrumented.unmeasurable.len(),
1083 instrumented.unmeasurable.join(", ")
1084 )
1085 .map_err(|error| error.to_string())?;
1086 }
1087 for (file, reason) in &instrumented.project.unparseable {
1088 writeln!(
1089 diagnostics,
1090 "[supercov] could not parse {file}: {reason}; it carries no obligations"
1091 )
1092 .map_err(|error| error.to_string())?;
1093 }
1094
1095 let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1096 if let Some(note) = note {
1097 writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1098 }
1099 let test_started = Instant::now();
1100 let plan = ExecutionPlan {
1101 preparation: Vec::new(),
1102 test: ExecutionPhase {
1103 name: "test".into(),
1104 kind: PhaseKind::Test,
1105 command: CommandSpec {
1106 program: command[0].clone().into(),
1107 arguments: command[1..].iter().map(OsString::from).collect(),
1108 cwd: workspace.clone(),
1109 environment: None,
1110 captured_output: None,
1111 },
1112 },
1113 };
1114 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1115 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1116 .map_err(|error| error.to_string())?;
1117 let test_command_ms = elapsed_ms(test_started);
1118 if let Some(signal) = execution.interrupted_signal {
1119 return Err(format!(
1120 "the test command was interrupted by {signal:?}; no run was published"
1121 ));
1122 }
1123 let exit_code = execution.exit_code;
1124
1125 let publication_started = Instant::now();
1126 let mut parts = Vec::new();
1128 let mut outcomes = Vec::new();
1129 let mut silent = Vec::new();
1130 for module in instrumented
1131 .modules
1132 .iter()
1133 .filter(|module| module.has_tests)
1134 {
1135 let mut written = fs::read_dir(&module.evidence)
1137 .map(|entries| {
1138 entries
1139 .flatten()
1140 .map(|entry| entry.path())
1141 .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1142 .collect::<Vec<_>>()
1143 })
1144 .unwrap_or_default();
1145 written.sort();
1146 if written.is_empty() {
1147 silent.push(module.directory.clone());
1148 continue;
1149 }
1150 let mut forked = Vec::new();
1151 for path in &written {
1152 let bytes =
1153 fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1154 forked.push(
1155 read_evidence(&bytes)
1156 .map_err(|error| format!("{}: {error}", path.display()))?,
1157 );
1158 }
1159 let evidence = merge_evidence(forked);
1160 for test in &evidence.tests {
1161 outcomes.push(OwnedTestOutcome {
1162 name: test.name.clone(),
1163 runner: test.runner.clone(),
1164 package: module.directory.clone(),
1168 file: class_of(&test.name)
1169 .and_then(|class| instrumented.declared_in.get(class))
1170 .cloned(),
1171 status: test.status.clone(),
1172 });
1173 }
1174 parts.push(evidence);
1175 }
1176 if !silent.is_empty() {
1177 writeln!(
1178 diagnostics,
1179 "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1180 silent.len(),
1181 silent.join(", ")
1182 )
1183 .map_err(|error| error.to_string())?;
1184 }
1185 if outcomes.is_empty() {
1186 return Err(format!(
1187 "the test run wrote no coverage evidence (the command exited {exit_code}). Supercov attributes through each framework's own lifecycle, so the suite has to run on the JUnit Platform or TestNG."
1188 ));
1189 }
1190 let evidence = merge_evidence(parts);
1191 let run = build_frontend_run(OwnedRunInputs {
1192 declaration: jvm_declaration(),
1193 environment: "jvm",
1194 manifest: &instrumented.project.manifest,
1195 probes: &instrumented.project.probes,
1196 evidence: &evidence,
1197 outcomes: &outcomes,
1198 run_id: &request.run_id,
1199 generated_at: &request.started_at,
1200 test_exit_code: exit_code,
1201 coverage_model: jvm_coverage_model(),
1202 })
1203 .map_err(|error| error.to_string())?;
1204 validate_frontend_report_request(&run.declaration, &run.request)
1205 .map_err(|error| error.to_string())?;
1206 let archive_path = work_directory.join("evidence.raw.gz");
1207 let raw = write_archive(
1208 crate::assertion_inputs::append(
1209 run.archive_entries().map_err(|error| error.to_string())?,
1210 &assertion_inputs,
1211 )?,
1212 &archive_path,
1213 )
1214 .map_err(|error| error.to_string())?;
1215 let evidence_publication_ms = elapsed_ms(publication_started);
1216
1217 let timings = RunTimings {
1218 initialization_ms,
1219 workspace_preparation_ms,
1220 adapter_setup_ms,
1221 instrumented_build_ms: 0.0,
1222 test_command_ms,
1223 evidence_publication_ms,
1224 };
1225 let metadata = RunMetadata {
1226 id: request.run_id.clone(),
1227 started_at: request.started_at.clone(),
1228 duration_ms: elapsed_ms(total_started),
1229 command: request.command.clone(),
1230 test_exit_code: Some(exit_code),
1231 integrity,
1232 raw_evidence: RawEvidenceMetadata {
1233 schema_version: raw.schema_version,
1234 format: raw.format.into(),
1235 file: raw.file.into(),
1236 files: raw.files,
1237 uncompressed_bytes: raw.uncompressed_bytes,
1238 compressed_bytes: raw.compressed_bytes,
1239 },
1240 isolated_build: Some(true),
1241 instrumented_build_cache: None,
1242 timings: Some(timings),
1243 merged: None,
1244 parents: None,
1245 };
1246 let run_directory =
1247 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1248 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1249 Ok(DirectJvmRunResult {
1250 run_id: request.run_id.clone(),
1251 run_directory,
1252 exit_code,
1253 tests: outcomes
1254 .iter()
1255 .map(|outcome| outcome.name.as_str())
1256 .collect::<BTreeSet<_>>()
1257 .len(),
1258 source_files: instrumented.project.instrumented.len(),
1259 modules: instrumented.modules.len(),
1260 build: instrumented.build,
1261 recovered_runs,
1262 metadata,
1263 })
1264 })();
1265 if result.is_err() {
1266 let _ = remove_stored_tree_deferred(&root, &work_directory);
1267 }
1268 let release = lock.release().map_err(|error| error.to_string());
1269 match (result, release) {
1270 (Ok(result), Ok(())) => Ok(result),
1271 (Err(error), _) => Err(error),
1272 (Ok(_), Err(error)) => Err(error),
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 #[test]
1281 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1282 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1286 junit.jupiter.execution.parallel.enabled=true\n\
1287 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1288 let updated = sequential_properties(Some(existing));
1289 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1290 assert!(
1291 !updated.contains("parallel.enabled=true"),
1292 "the project's own setting must not survive:\n{updated}"
1293 );
1294 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1296 assert!(updated.contains("displayname.generator.default"));
1297
1298 assert!(
1300 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1301 );
1302 }
1303
1304 #[test]
1305 fn gradle_is_told_to_run_the_tests_again() {
1306 let (command, note) = command_with_fresh_results(
1309 JvmBuild::Gradle,
1310 &["./gradlew".to_owned(), "test".to_owned()],
1311 );
1312 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1313 assert!(note.is_some());
1314
1315 let (command, note) = command_with_fresh_results(
1317 JvmBuild::Gradle,
1318 &[
1319 "./gradlew".to_owned(),
1320 "test".to_owned(),
1321 "--rerun".to_owned(),
1322 ],
1323 );
1324 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1325 assert!(note.is_none());
1326
1327 let (command, note) =
1329 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1330 assert_eq!(command, ["mvn", "test"]);
1331 assert!(note.is_none());
1332 }
1333
1334 #[test]
1335 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1336 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1339 assert!(
1340 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1341 "{configuration}"
1342 );
1343 assert!(configuration.contains("PROBES = 7"));
1344 assert!(configuration.contains("new int[] {2, 3}"));
1345 }
1346
1347 #[test]
1348 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1349 assert_eq!(
1352 class_of("CalculatorTest#zeroIsNamed()"),
1353 Some("CalculatorTest")
1354 );
1355 assert_eq!(
1356 class_of("CalculatorSpec#a sum adds its parts"),
1357 Some("CalculatorSpec")
1358 );
1359 assert_eq!(class_of("Standalone"), Some("Standalone"));
1360 assert_eq!(class_of(""), None);
1361 }
1362
1363 #[test]
1364 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1365 assert_eq!(
1370 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1371 .as_deref(),
1372 Some("1.10.2")
1373 );
1374 assert_eq!(
1375 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1376 .as_deref(),
1377 Some("1.13.1")
1378 );
1379 assert_eq!(
1382 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1383 None
1384 );
1385 assert_eq!(
1388 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1389 Some(DEFAULT_LAUNCHER_VERSION)
1390 );
1391 assert_eq!(
1397 launcher_version("<artifactId>parent</artifactId>").as_deref(),
1398 Some(DEFAULT_LAUNCHER_VERSION)
1399 );
1400 assert_eq!(
1401 launcher_version("").as_deref(),
1402 Some(DEFAULT_LAUNCHER_VERSION)
1403 );
1404 }
1405
1406 #[test]
1407 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1408 let pom = "<project>\n <dependencies>\n <dependency>\n <groupId>org.junit.jupiter</groupId>\n <artifactId>junit-jupiter</artifactId>\n <version>5.10.2</version>\n <scope>test</scope>\n </dependency>\n </dependencies>\n</project>\n";
1409 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1410 assert!(updated.contains("junit-platform-launcher"), "{updated}");
1411 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1412 assert!(
1414 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1415 "{updated}"
1416 );
1417 assert_eq!(maven_with_launcher(&updated), None);
1419
1420 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
1422 let updated = maven_with_launcher(bare).expect("a block is created");
1423 assert!(updated.contains("<dependencies>"), "{updated}");
1424 assert!(
1425 updated.find("</dependencies>") < updated.find("</project>"),
1426 "{updated}"
1427 );
1428 }
1429
1430 #[test]
1431 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1432 let groovy =
1433 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1434 let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1435 assert!(
1436 updated
1437 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1438 "{updated}"
1439 );
1440 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1442 assert_eq!(gradle_with_launcher(&updated, false), None);
1443
1444 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1450 let updated = gradle_with_launcher(runtime_only, false)
1451 .expect("a runtime-only declaration does not reach the compiler");
1452 assert!(
1453 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1454 "{updated}"
1455 );
1456 assert!(
1457 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1458 "the project's own declaration stays:\n{updated}"
1459 );
1460
1461 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1462 let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1463 assert!(
1464 updated.contains(
1465 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1466 ),
1467 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1468 );
1469 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1470 }
1471
1472 #[test]
1473 fn only_the_listeners_a_project_can_compile_are_written() {
1474 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1478 assert!(junit.platform && !junit.testng);
1479
1480 let testng = frameworks("<artifactId>testng</artifactId>");
1481 assert!(testng.testng && !testng.platform);
1482
1483 let both = frameworks("testng ... junit-jupiter");
1486 assert!(both.platform && both.testng);
1487
1488 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1491 assert!(frameworks("org.spockframework:spock-core").platform);
1492
1493 let unknown = frameworks("<artifactId>demo</artifactId>");
1496 assert!(unknown.platform && !unknown.testng);
1497 }
1498
1499 #[test]
1500 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1501 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1507 assert!(four.junit4 && !four.platform && !four.testng);
1508
1509 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1510 assert!(five.platform && !five.junit4);
1511
1512 let both = frameworks(
1515 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1516 );
1517 assert!(both.platform && !both.junit4);
1518
1519 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1521 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1522 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1523 }
1524
1525 #[test]
1526 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1527 let root = std::env::temp_dir().join(format!(
1534 "supercov-jpms-{}-{}",
1535 std::process::id(),
1536 std::time::SystemTime::now()
1537 .duration_since(std::time::UNIX_EPOCH)
1538 .unwrap()
1539 .as_nanos()
1540 ));
1541 write(&root.join("pom.xml"), "<project>\n <modules>\n <module>lib</module>\n <module>boundaries</module>\n </modules>\n <dependencies>\n <dependency>\n <groupId>org.junit.jupiter</groupId>\n <artifactId>junit-jupiter</artifactId>\n </dependency>\n </dependencies>\n</project>\n").unwrap();
1542 for module in ["lib", "boundaries"] {
1543 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1544 write(
1545 &root.join(module).join("src/main/java/app/Api.java"),
1546 "package app;\npublic class Api { public int one() { return 1; } }\n",
1547 )
1548 .unwrap();
1549 write(
1550 &root.join(module).join("src/test/java/app/ApiTest.java"),
1551 "package app;\nclass ApiTest { void t() {} }\n",
1552 )
1553 .unwrap();
1554 }
1555 write(
1556 &root.join("boundaries/src/test/java/module-info.java"),
1557 "module app.boundaries {\n requires app.lib;\n}\n",
1558 )
1559 .unwrap();
1560
1561 let evidence = root.join("evidence");
1562 let instrumented = instrument_workspace(&root, &evidence).expect("instrument");
1563 assert_eq!(instrumented.modular, ["boundaries"]);
1564
1565 assert!(
1567 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1568 .exists()
1569 );
1570 for name in ["SupercovListener.java", "SupercovConfig.java"] {
1571 assert!(
1572 !root
1573 .join("boundaries/src/test/java/com/supercorp/supercov")
1574 .join(name)
1575 .exists(),
1576 "{name} must not be written into a named module"
1577 );
1578 }
1579 assert!(
1580 !root
1581 .join("boundaries/src/test/resources")
1582 .join(SERVICES_FILE)
1583 .exists(),
1584 "and nothing registers a listener that is not there"
1585 );
1586 assert!(
1590 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1591 .exists()
1592 );
1593
1594 fs::remove_dir_all(root).ok();
1595 }
1596
1597 #[test]
1598 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1599 let root = std::env::temp_dir().join(format!(
1604 "supercov-catalog-{}-{}",
1605 std::process::id(),
1606 std::time::SystemTime::now()
1607 .duration_since(std::time::UNIX_EPOCH)
1608 .unwrap()
1609 .as_nanos()
1610 ));
1611 write(
1612 &root.join("gradle/libs.versions.toml"),
1613 "[versions]\nkotlin = \"2.0.0\"\n\n[libraries]\njunit = \"junit:junit:4.13.2\"\nkotlin-reflect = { module = \"org.jetbrains.kotlin:kotlin-reflect\", version.ref = \"kotlin\" }\njupiter = { group = \"org.junit.jupiter\", name = \"junit-jupiter\" }\n\n[bundles]\nunit = [\"junit\", \"kotlin-reflect\"]\n",
1614 )
1615 .unwrap();
1616 let catalog = version_catalog(&root);
1617
1618 let junit4 = frameworks(&with_catalog(
1619 "dependencies { testImplementation(libs.junit) }",
1620 &catalog,
1621 ));
1622 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1623
1624 let reflect = frameworks(&with_catalog(
1626 "dependencies { testImplementation(libs.kotlin.reflect) }",
1627 &catalog,
1628 ));
1629 assert!(!reflect.junit4, "{reflect:?}");
1630
1631 let jupiter = frameworks(&with_catalog(
1632 "dependencies { testImplementation(libs.jupiter) }",
1633 &catalog,
1634 ));
1635 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1636
1637 let bundle = frameworks(&with_catalog(
1639 "dependencies { testImplementation(libs.bundles.unit) }",
1640 &catalog,
1641 ));
1642 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1643
1644 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1646 assert_eq!(with_catalog(plain, &catalog), plain);
1647
1648 fs::remove_dir_all(root).ok();
1649 }
1650
1651 #[test]
1652 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1653 let pom = "<project>\n <dependencyManagement>\n <dependencies>\n <dependency>\n <groupId>org.junit</groupId>\n <artifactId>junit-bom</artifactId>\n <version>5.10.2</version>\n </dependency>\n </dependencies>\n </dependencyManagement>\n <dependencies>\n <dependency>\n <groupId>org.junit.jupiter</groupId>\n <artifactId>junit-jupiter</artifactId>\n </dependency>\n </dependencies>\n <build>\n <plugins>\n <plugin>\n <dependencies>\n <dependency><groupId>x</groupId></dependency>\n </dependencies>\n </plugin>\n </plugins>\n </build>\n</project>\n";
1659 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1660 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1661 let managed_end = updated
1662 .find("</dependencyManagement>")
1663 .expect("managed block");
1664 let build_start = updated.find("<build>").expect("build block");
1665 assert!(
1666 at > managed_end,
1667 "not among the managed versions:\n{updated}"
1668 );
1669 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1670 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1673 }
1674
1675 #[test]
1676 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1677 let pom = "<project>\n <failOnWarning>true</failOnWarning>\n <compilerArgs>\n <arg>-XDcompilePolicy=simple</arg>\n <arg>-Xplugin:ErrorProne\n -Xep:NotJavadoc:OFF\n </arg>\n </compilerArgs>\n</project>\n";
1683 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1684 assert!(
1685 updated.contains("<failOnWarning>false</failOnWarning>"),
1686 "{updated}"
1687 );
1688 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1689 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1692
1693 assert_eq!(without_warnings_as_errors("<project></project>"), None);
1695
1696 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1699 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1700 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1701
1702 let only_analyser =
1703 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1704 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1705 assert!(!updated.contains("ErrorProne"), "{updated}");
1706 }
1707
1708 #[test]
1709 fn vintage_carries_the_engine_version_not_the_platform_one() {
1710 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1714 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1715
1716 let pom = "<project>\n <dependencies>\n <dependency>\n <groupId>junit</groupId>\n <artifactId>junit</artifactId>\n <version>4.13.2</version>\n </dependency>\n </dependencies>\n</project>\n";
1717 let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1718 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1719 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1720 assert!(
1721 updated.contains("<groupId>org.junit.vintage</groupId>"),
1722 "{updated}"
1723 );
1724 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1727 assert!(
1728 updated.contains(&format!(
1729 "<version>{}</version>",
1730 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1731 )),
1732 "{updated}"
1733 );
1734
1735 assert_eq!(maven_with_vintage(&updated), None);
1737 }
1738}