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 #[serde(default)]
218 pub exact_attribution: bool,
219}
220
221#[derive(Debug, Clone, PartialEq)]
222pub struct DirectJvmRunResult {
223 pub run_id: String,
224 pub run_directory: PathBuf,
225 pub exit_code: i32,
226 pub tests: usize,
227 pub source_files: usize,
228 pub modules: usize,
229 pub build: JvmBuild,
230 pub recovered_runs: Vec<String>,
231 pub metadata: RunMetadata,
232}
233
234fn elapsed_ms(started: Instant) -> f64 {
235 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
236}
237
238fn write(path: &Path, contents: &str) -> Result<(), String> {
239 if let Some(parent) = path.parent() {
240 fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
241 }
242 fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
243}
244
245fn source_root(source_set: &str) -> String {
251 format!("src/{source_set}/java")
252}
253
254fn java_literal(value: &str) -> String {
257 let mut out = String::with_capacity(value.len() + 2);
258 out.push('"');
259 for character in value.chars() {
260 match character {
261 '"' => out.push_str("\\\""),
262 '\\' => out.push_str("\\\\"),
263 '\n' => out.push_str("\\n"),
264 '\r' => out.push_str("\\r"),
265 other => out.push(other),
266 }
267 }
268 out.push('"');
269 out
270}
271
272fn runtime_source(probe_count: usize) -> String {
282 let marker = "static final int PROBE_COUNT = 0; // supercov:probe-count";
283 debug_assert!(
284 RUNTIME_SOURCE.contains(marker),
285 "the runtime no longer declares the probe count Supercov substitutes"
286 );
287 RUNTIME_SOURCE.replace(
288 marker,
289 &format!("static final int PROBE_COUNT = {probe_count}; // supercov:probe-count"),
290 )
291}
292
293fn configuration(probe_count: usize, widths: &[u8], evidence: &Path) -> String {
295 let widths = widths
296 .iter()
297 .map(u8::to_string)
298 .collect::<Vec<_>>()
299 .join(", ");
300 format!(
301 "// 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",
302 java_literal(&evidence.to_string_lossy())
303 )
304}
305
306fn asks_for_parallel_execution(existing: Option<&str>) -> bool {
312 existing.unwrap_or("").lines().any(|line| {
313 let line = line.trim_start();
314 line.starts_with("junit.jupiter.execution.parallel.enabled")
315 && line
316 .split_once('=')
317 .is_some_and(|(_, value)| value.trim().eq_ignore_ascii_case("true"))
318 })
319}
320
321fn sequential_properties(existing: Option<&str>) -> String {
328 let mut out = String::new();
329 for line in existing.unwrap_or("").lines() {
330 if line
331 .trim_start()
332 .starts_with("junit.jupiter.execution.parallel.enabled")
333 {
334 continue;
335 }
336 out.push_str(line);
337 out.push('\n');
338 }
339 out.push_str(
340 "# Set by Supercov: probes are a store into one shared array, so two tests running\n\
341 # at once cannot both be credited with what they reached.\n\
342 junit.jupiter.execution.parallel.enabled=false\n",
343 );
344 out
345}
346
347const LAUNCHER_ARTIFACT: &str = "junit-platform-launcher";
356
357fn launcher_version(build_file: &str) -> Option<String> {
365 if build_file.contains("junit-bom") {
371 return None;
372 }
373 Some(jupiter_version(build_file).unwrap_or_else(|| DEFAULT_LAUNCHER_VERSION.to_owned()))
374}
375
376fn jupiter_version(build_file: &str) -> Option<String> {
379 let jupiter = build_file.find("junit-jupiter")?;
380 let rest = &build_file[jupiter..];
381 rest.match_indices("5.").find_map(|(at, _)| {
382 let tail = &rest[at + 2..];
383 let minor = tail
384 .chars()
385 .take_while(char::is_ascii_digit)
386 .collect::<String>();
387 let patch = tail[minor.len()..]
388 .strip_prefix('.')?
389 .chars()
390 .take_while(char::is_ascii_digit)
391 .collect::<String>();
392 (!minor.is_empty() && !patch.is_empty()).then(|| format!("1.{minor}.{patch}"))
393 })
394}
395
396const DEFAULT_LAUNCHER_VERSION: &str = "1.10.2";
397
398fn engine_version_of_platform(platform: &str) -> String {
400 match platform.strip_prefix("1.") {
401 Some(rest) => format!("5.{rest}"),
402 None => platform.to_owned(),
403 }
404}
405
406fn maven_with_launcher(pom: &str) -> Option<String> {
408 maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT])
409}
410
411const VINTAGE_ARTIFACT: &str = "junit-vintage-engine";
419
420fn maven_with_vintage(pom: &str) -> Option<String> {
421 maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT, VINTAGE_ARTIFACT])
422}
423
424fn maven_with_test_artifacts(pom: &str, artifacts: &[&str]) -> Option<String> {
425 let missing = artifacts
426 .iter()
427 .filter(|artifact| !pom.contains(**artifact))
428 .collect::<Vec<_>>();
429 if missing.is_empty() {
430 return None;
431 }
432 let platform = launcher_version(pom);
433 let dependency = missing
434 .iter()
435 .map(|artifact| {
436 let (group, version) = if **artifact == VINTAGE_ARTIFACT {
440 (
441 "org.junit.vintage",
442 platform.as_deref().map(engine_version_of_platform),
443 )
444 } else {
445 ("org.junit.platform", platform.clone())
446 };
447 let version = version
448 .map(|version| format!("\n <version>{version}</version>"))
449 .unwrap_or_default();
450 format!(
451 " <dependency>\n <groupId>{group}</groupId>\n <artifactId>{artifact}</artifactId>{version}\n <scope>test</scope>\n </dependency>\n"
452 )
453 })
454 .collect::<String>();
455 match project_dependencies_end(pom) {
456 Some(at) => Some(format!("{}{dependency}{}", &pom[..at], &pom[at..])),
457 None => pom.rfind("</project>").map(|at| {
459 format!(
460 "{} <dependencies>\n{dependency} </dependencies>\n{}",
461 &pom[..at],
462 &pom[at..]
463 )
464 }),
465 }
466}
467
468fn project_dependencies_end(pom: &str) -> Option<usize> {
477 const NESTED: [&str; 4] = ["dependencyManagement", "profiles", "build", "reporting"];
478 let bytes = pom.as_bytes();
479 let mut depth = 0usize;
480 let mut at = 0usize;
481 while at < bytes.len() {
482 let Some(open) = pom[at..].find('<') else {
483 break;
484 };
485 let start = at + open;
486 let Some(close) = pom[start..].find('>') else {
487 break;
488 };
489 let tag = &pom[start + 1..start + close];
490 at = start + close + 1;
491 let name = tag.trim_start_matches('/').trim_end_matches('/').trim();
492 let name = name.split_whitespace().next().unwrap_or_default();
493 if NESTED.contains(&name) {
494 if tag.starts_with('/') {
495 depth = depth.saturating_sub(1);
496 } else if !tag.ends_with('/') {
497 depth += 1;
498 }
499 } else if name == "dependencies" && tag.starts_with('/') && depth == 0 {
500 return Some(start);
501 }
502 }
503 None
504}
505
506fn declares_launcher_for_compilation(build_file: &str) -> bool {
516 build_file.lines().any(|line| {
517 line.contains(LAUNCHER_ARTIFACT)
518 && ["testImplementation", "testCompileOnly", "testApi"]
519 .iter()
520 .any(|configuration| line.contains(configuration))
521 })
522}
523
524fn gradle_with_launcher(build_file: &str, kotlin: bool) -> Option<String> {
531 if declares_launcher_for_compilation(build_file) {
532 return None;
533 }
534 let coordinate = match launcher_version(build_file) {
535 Some(version) => format!("org.junit.platform:{LAUNCHER_ARTIFACT}:{version}"),
536 None => format!("org.junit.platform:{LAUNCHER_ARTIFACT}"),
537 };
538 let line = if kotlin {
546 format!(" \"testImplementation\"(\"{coordinate}\")")
547 } else {
548 format!(" testImplementation '{coordinate}'")
549 };
550 let plugin = if kotlin { "\"java\"" } else { "'java'" };
551 Some(format!(
552 "{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"
553 ))
554}
555
556fn without_warnings_as_errors(build_file: &str) -> Option<String> {
569 let mut updated = build_file.to_owned();
570 for (from, to) in [
571 (
572 "<failOnWarning>true</failOnWarning>",
573 "<failOnWarning>false</failOnWarning>",
574 ),
575 (
576 "<failOnWarnings>true</failOnWarnings>",
577 "<failOnWarnings>false</failOnWarnings>",
578 ),
579 ("<arg>-Werror</arg>", ""),
580 ("<compilerArgument>-Werror</compilerArgument>", ""),
581 ("options.compilerArgs << '-Werror'", ""),
582 ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
583 ] {
584 updated = updated.replace(from, to);
585 }
586 updated = without_error_prone(&updated);
587 (updated != build_file).then_some(updated)
588}
589
590fn without_error_prone(build_file: &str) -> String {
602 let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
603 return build_file.to_owned();
604 };
605 let Some(end) = build_file[start..].find("</arg>") else {
606 return build_file.to_owned();
607 };
608 let mut updated = build_file.to_owned();
609 updated.replace_range(start..start + end + "</arg>".len(), "");
610 updated
611}
612
613fn command_with_fresh_results(
618 build: JvmBuild,
619 command: &[String],
620) -> (Vec<String>, Option<String>) {
621 let mut updated = command.to_vec();
622 match build {
623 JvmBuild::Gradle => {
624 if updated
625 .iter()
626 .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
627 {
628 return (updated, None);
629 }
630 updated.push("--rerun-tasks".into());
631 (
632 updated,
633 Some(
634 "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
635 .into(),
636 ),
637 )
638 }
639 JvmBuild::Maven | JvmBuild::Plain => (updated, None),
642 }
643}
644
645#[derive(Debug, Clone)]
654struct JvmModule {
655 directory: String,
657 evidence: PathBuf,
661 has_tests: bool,
662}
663
664struct InstrumentedWorkspace {
665 project: PreparedJvmProject,
666 build: JvmBuild,
667 modules: Vec<JvmModule>,
668 declared_in: BTreeMap<String, String>,
670 added_launcher: Option<&'static str>,
672 serialised: BTreeSet<String>,
676 concurrent: BTreeSet<String>,
679 added_vintage: bool,
682 relaxed: Vec<&'static str>,
685 unmeasurable: Vec<String>,
688 modular: Vec<String>,
690}
691
692fn module_of(relative: &str) -> String {
700 match relative.find("src/") {
701 Some(0) | None => ".".to_owned(),
702 Some(at) => relative[..at].trim_end_matches('/').to_owned(),
703 }
704}
705
706fn class_of(test_name: &str) -> Option<&str> {
709 test_name.split('#').next().filter(|name| !name.is_empty())
710}
711
712fn instrument_workspace(
713 workspace: &Path,
714 evidence_directory: &Path,
715 exact_attribution: bool,
716 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
717) -> Result<InstrumentedWorkspace, String> {
718 let project = prepare_jvm_project(workspace, roots)?;
719 let build = detect_build(workspace);
720
721 for (relative, instrumented) in &project.instrumented {
722 write(&workspace.join(relative), instrumented)?;
723 }
724
725 let probe_count = project
726 .probes
727 .keys()
728 .max()
729 .map_or(0, |highest| *highest as usize + 1);
730
731 let catalog = version_catalog(workspace);
737 let frameworks_of = |directory: &str| -> Frameworks {
738 let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
739 let mut text = names
740 .iter()
741 .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
742 .unwrap_or_default();
743 for name in names {
744 if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
745 text.push('\n');
746 text.push_str(&own);
747 break;
748 }
749 }
750 frameworks(&with_catalog(&text, &catalog))
751 };
752 let mut unmeasurable: Vec<String> = Vec::new();
753 let mut modular: Vec<String> = Vec::new();
754
755 let mut modules: BTreeMap<String, bool> = BTreeMap::new();
758 for (relative, _) in &project.files.sources {
759 modules.entry(module_of(relative)).or_insert(false);
760 }
761 for (relative, _) in &project.files.tests {
762 *modules.entry(module_of(relative)).or_default() = true;
763 }
764 for module in modules.keys().cloned().collect::<Vec<_>>() {
771 if workspace.join(&module).join("src/test").is_dir() {
772 modules.insert(module, true);
773 }
774 }
775 if modules.is_empty() {
776 modules.insert(".".to_owned(), true);
777 }
778
779 let modules = modules
780 .into_iter()
781 .map(|(directory, has_tests)| JvmModule {
782 evidence: evidence_directory.join(
783 directory
784 .chars()
785 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
786 .collect::<String>(),
787 ),
788 directory,
789 has_tests,
790 })
791 .collect::<Vec<_>>();
792
793 for module in &modules {
794 let frameworks = frameworks_of(&module.directory);
795 let at = |source_set: &str| {
796 workspace
797 .join(&module.directory)
798 .join(source_root(source_set))
799 .join(PACKAGE_DIRECTORY)
800 };
801 write(
806 &at("main").join("Supercov.java"),
807 &runtime_source(probe_count),
808 )?;
809 if !module.has_tests {
810 continue;
811 }
812 if frameworks.junit4 && build != JvmBuild::Maven {
813 unmeasurable.push(module.directory.clone());
817 continue;
818 }
819 if workspace
829 .join(&module.directory)
830 .join(source_root("test"))
831 .join("module-info.java")
832 .exists()
833 {
834 modular.push(module.directory.clone());
835 continue;
836 }
837 let test = at("test");
840 write(
841 &test.join("SupercovConfig.java"),
842 &configuration(probe_count, &project.decision_widths, &module.evidence),
843 )?;
844 if frameworks.platform || frameworks.junit4 {
845 write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
846 }
847 if frameworks.testng {
848 write(
849 &test.join("SupercovTestNGListener.java"),
850 TESTNG_LISTENER_SOURCE,
851 )?;
852 }
853 }
854
855 let mut relaxed: Vec<&'static str> = Vec::new();
858 for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
859 let path = workspace.join(name);
860 let Ok(existing) = fs::read_to_string(&path) else {
861 continue;
862 };
863 let Some(updated) = without_warnings_as_errors(&existing) else {
864 continue;
865 };
866 if !relaxed.contains(&"stopped the build failing on warnings")
870 && updated.contains("<failOnWarning>false</failOnWarning>")
871 != existing.contains("<failOnWarning>false</failOnWarning>")
872 || existing.contains("-Werror") && !updated.contains("-Werror")
873 {
874 relaxed.push("stopped the build failing on warnings");
875 }
876 if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
877 relaxed.push("switched Error Prone off");
878 }
879 write(&path, &updated)?;
880 }
881 relaxed.dedup();
882
883 let mut added_launcher = None;
888 let mut serialised: BTreeSet<String> = BTreeSet::new();
889 let mut concurrent: BTreeSet<String> = BTreeSet::new();
890 let mut added_vintage = false;
891 match build {
892 JvmBuild::Maven => {
896 for module in modules
897 .iter()
898 .filter(|module| {
899 module.has_tests
900 && !unmeasurable.contains(&module.directory)
901 && !modular.contains(&module.directory)
902 })
903 .filter(|module| {
907 let frameworks = frameworks_of(&module.directory);
908 frameworks.platform || frameworks.junit4
909 })
910 {
911 let pom = workspace.join(&module.directory).join("pom.xml");
912 let Ok(existing) = fs::read_to_string(&pom) else {
913 continue;
914 };
915 let updated = if frameworks_of(&module.directory).junit4 {
919 added_vintage = true;
920 maven_with_vintage(&existing)
921 } else {
922 maven_with_launcher(&existing)
923 };
924 if let Some(updated) = updated {
925 write(&pom, &updated)?;
926 added_launcher = Some("pom.xml");
927 }
928 }
929 }
930 JvmBuild::Gradle
931 if !modules
932 .iter()
933 .any(|module| module.has_tests && frameworks_of(&module.directory).platform) => {}
934 JvmBuild::Gradle => {
935 for name in ["build.gradle.kts", "build.gradle"] {
936 let path = workspace.join(name);
937 let Ok(existing) = fs::read_to_string(&path) else {
938 continue;
939 };
940 if let Some(updated) = gradle_with_launcher(&existing, name.ends_with(".kts")) {
941 write(&path, &updated)?;
942 added_launcher = Some(if name.ends_with(".kts") {
943 "build.gradle.kts"
944 } else {
945 "build.gradle"
946 });
947 }
948 break;
949 }
950 }
951 JvmBuild::Plain => {}
954 }
955
956 for module in modules.iter().filter(|module| {
957 module.has_tests
958 && !unmeasurable.contains(&module.directory)
959 && !modular.contains(&module.directory)
960 }) {
961 let frameworks = frameworks_of(&module.directory);
962 let resources = workspace.join(&module.directory).join("src/test/resources");
963 if frameworks.platform || frameworks.junit4 {
964 write(
965 &resources.join(SERVICES_FILE),
966 &format!("{LISTENER_CLASS}\n"),
967 )?;
968 let properties = resources.join("junit-platform.properties");
969 let existing = fs::read_to_string(&properties).ok();
970 if !exact_attribution {
971 if asks_for_parallel_execution(existing.as_deref()) {
977 concurrent.insert(module.directory.clone());
978 }
979 continue;
980 }
981 if asks_for_parallel_execution(existing.as_deref()) {
988 serialised.insert(module.directory.clone());
989 }
990 write(&properties, &sequential_properties(existing.as_deref()))?;
991 }
992 if frameworks.testng {
993 write(
994 &resources.join(TESTNG_SERVICES_FILE),
995 &format!("{TESTNG_LISTENER_CLASS}\n"),
996 )?;
997 }
998 }
999
1000 let mut declared_in = BTreeMap::new();
1004 for (relative, _) in &project.files.tests {
1005 if let Some(stem) = relative
1006 .rsplit('/')
1007 .next()
1008 .and_then(|name| name.split('.').next())
1009 {
1010 declared_in.insert(stem.to_owned(), relative.clone());
1011 }
1012 }
1013
1014 Ok(InstrumentedWorkspace {
1015 project,
1016 build,
1017 modules,
1018 declared_in,
1019 added_launcher,
1020 serialised,
1021 concurrent,
1022 added_vintage,
1023 relaxed,
1024 unmeasurable,
1025 modular,
1026 })
1027}
1028
1029pub fn current_jvm_integrity(
1031 root: &Path,
1032 command: &[String],
1033 source_roots: Option<&[String]>,
1034) -> Result<crate::run_store::RunIntegrity, String> {
1035 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1036 let files = crate::jvm_project::discover_jvm_files(&root)?;
1037 let mut inputs = jvm_integrity_inputs(&files, command);
1038 crate::source_discovery::fold_roots_into_configuration(
1039 &mut inputs.execution_configuration,
1040 source_roots,
1041 );
1042 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_jvm())
1043 .map_err(|error| error.to_string())
1044}
1045
1046pub fn run_direct_jvm(
1047 request: &DirectJvmRunRequest,
1048 diagnostics: &mut dyn Write,
1049) -> Result<DirectJvmRunResult, String> {
1050 if request.command.is_empty() {
1051 return Err("test command must not be empty".into());
1052 }
1053 let total_started = Instant::now();
1054 let initialization_started = Instant::now();
1055 let root = canonicalize_simplified(&request.root)
1056 .map_err(|error| format!("{}: {error}", request.root.display()))?;
1057 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1058 .map_err(|error| error.to_string())?;
1059 let initialization_ms = elapsed_ms(initialization_started);
1060 let work_directory = root.join(".supercov/work").join(&request.run_id);
1061 let result = (|| {
1062 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1063 .map_err(|error| error.to_string())?;
1064 if !recovered_runs.is_empty() {
1065 writeln!(
1066 diagnostics,
1067 "[supercov] recovered abandoned run(s): {}",
1068 recovered_runs.join(", ")
1069 )
1070 .map_err(|error| error.to_string())?;
1071 }
1072
1073 let adapter_started = Instant::now();
1074 let files = crate::jvm_project::discover_jvm_files(&root)?;
1075 let source_roots = crate::source_discovery::configured_source_roots(
1076 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1077 );
1078 let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1079 crate::source_discovery::fold_roots_into_configuration(
1080 &mut integrity_inputs.execution_configuration,
1081 source_roots.as_deref(),
1082 );
1083 let assertion_inputs =
1084 crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1085 let integrity = create_explicit_run_integrity(
1086 &root,
1087 &integrity_inputs,
1088 &FrontendIntegrityInputs::embedded_jvm(),
1089 )
1090 .map_err(|error| error.to_string())?;
1091
1092 let workspace_started = Instant::now();
1093 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1094 let workspace =
1095 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1096 let evidence_directory = work_directory.join("jvm");
1097 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1098 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1099 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1100 &root, &workspace, &ambient,
1101 )
1102 .map_err(|error| error.to_string())?;
1103 let instrumented = instrument_workspace(
1104 &workspace,
1105 &evidence_directory,
1106 request.exact_attribution,
1107 roots.as_ref(),
1108 )?;
1109 let workspace_preparation_ms = elapsed_ms(workspace_started);
1110 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1111 writeln!(
1112 diagnostics,
1113 "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1114 match instrumented.build {
1115 JvmBuild::Maven => "a Maven project",
1116 JvmBuild::Gradle => "a Gradle project",
1117 JvmBuild::Plain => "Java/Kotlin sources",
1118 },
1119 instrumented.project.instrumented.len(),
1120 workspace.display()
1121 )
1122 .map_err(|error| error.to_string())?;
1123 if let Some(build_file) = instrumented.added_launcher {
1124 writeln!(
1125 diagnostics,
1126 "[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."
1127 )
1128 .map_err(|error| error.to_string())?;
1129 }
1130 if !instrumented.serialised.is_empty() {
1131 writeln!(
1132 diagnostics,
1133 "[supercov] turned JUnit's parallel execution off in the workspace copy ({}): attribution is a sweep of one shared probe array at each test boundary, so two tests running at once cannot both be credited with what they reached. Your suite runs in order here and your own configuration is untouched.",
1134 instrumented
1135 .serialised
1136 .iter()
1137 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1138 .collect::<Vec<_>>()
1139 .join(", ")
1140 )
1141 .map_err(|error| error.to_string())?;
1142 }
1143 if !instrumented.concurrent.is_empty() {
1144 writeln!(
1145 diagnostics,
1146 "[supercov] JUnit runs this suite's tests at once ({}), so what they reach while they overlap is recorded against the run rather than against a test. `--exact-attribution` credits each of them instead, by running the suite in order.",
1147 instrumented
1148 .concurrent
1149 .iter()
1150 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1151 .collect::<Vec<_>>()
1152 .join(", ")
1153 )
1154 .map_err(|error| error.to_string())?;
1155 }
1156 if !instrumented.relaxed.is_empty() {
1157 writeln!(
1158 diagnostics,
1159 "[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.",
1160 instrumented.relaxed.join("; ")
1161 )
1162 .map_err(|error| error.to_string())?;
1163 }
1164 if instrumented.added_vintage {
1165 writeln!(
1166 diagnostics,
1167 "[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."
1168 )
1169 .map_err(|error| error.to_string())?;
1170 }
1171 if !instrumented.modular.is_empty() {
1172 writeln!(
1173 diagnostics,
1174 "[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.",
1175 instrumented.modular.len(),
1176 instrumented.modular.join(", ")
1177 )
1178 .map_err(|error| error.to_string())?;
1179 }
1180 if !instrumented.unmeasurable.is_empty() {
1181 writeln!(
1182 diagnostics,
1183 "[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.",
1184 instrumented.unmeasurable.len(),
1185 instrumented.unmeasurable.join(", ")
1186 )
1187 .map_err(|error| error.to_string())?;
1188 }
1189 for (file, reason) in &instrumented.project.unparseable {
1190 writeln!(
1191 diagnostics,
1192 "[supercov] could not parse {file}: {reason}; it carries no obligations"
1193 )
1194 .map_err(|error| error.to_string())?;
1195 }
1196
1197 let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1198 if let Some(note) = note {
1199 writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1200 }
1201 let test_started = Instant::now();
1202 let plan = ExecutionPlan {
1203 preparation: Vec::new(),
1204 test: ExecutionPhase {
1205 name: "test".into(),
1206 kind: PhaseKind::Test,
1207 command: CommandSpec {
1208 program: command[0].clone().into(),
1209 arguments: command[1..].iter().map(OsString::from).collect(),
1210 cwd: workspace.clone(),
1211 environment: None,
1212 captured_output: None,
1213 },
1214 },
1215 };
1216 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1217 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1218 .map_err(|error| error.to_string())?;
1219 let test_command_ms = elapsed_ms(test_started);
1220 if let Some(signal) = execution.interrupted_signal {
1221 return Err(format!(
1222 "the test command was interrupted by {signal:?}; no run was published"
1223 ));
1224 }
1225 let exit_code = execution.exit_code;
1226
1227 let publication_started = Instant::now();
1228 let mut parts = Vec::new();
1230 let mut outcomes = Vec::new();
1231 let mut silent = Vec::new();
1232 for module in instrumented
1233 .modules
1234 .iter()
1235 .filter(|module| module.has_tests)
1236 {
1237 let mut written = fs::read_dir(&module.evidence)
1239 .map(|entries| {
1240 entries
1241 .flatten()
1242 .map(|entry| entry.path())
1243 .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1244 .collect::<Vec<_>>()
1245 })
1246 .unwrap_or_default();
1247 written.sort();
1248 if written.is_empty() {
1249 silent.push(module.directory.clone());
1250 continue;
1251 }
1252 let mut forked = Vec::new();
1253 for path in &written {
1254 let bytes =
1255 fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1256 forked.push(
1257 read_evidence(&bytes)
1258 .map_err(|error| format!("{}: {error}", path.display()))?,
1259 );
1260 }
1261 let evidence = merge_evidence(forked);
1262 for test in &evidence.tests {
1263 outcomes.push(OwnedTestOutcome {
1264 name: test.name.clone(),
1265 runner: test.runner.clone(),
1266 package: module.directory.clone(),
1270 file: class_of(&test.name)
1271 .and_then(|class| instrumented.declared_in.get(class))
1272 .cloned(),
1273 status: test.status.clone(),
1274 attributed: !test.unattributed,
1280 });
1281 }
1282 parts.push(evidence);
1283 }
1284 if !silent.is_empty() {
1285 writeln!(
1286 diagnostics,
1287 "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1288 silent.len(),
1289 silent.join(", ")
1290 )
1291 .map_err(|error| error.to_string())?;
1292 }
1293 if outcomes.is_empty() {
1294 return Err(format!(
1295 "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."
1296 ));
1297 }
1298 let evidence = merge_evidence(parts);
1299 let run = build_frontend_run(OwnedRunInputs {
1300 declaration: jvm_declaration(),
1301 environment: "jvm",
1302 manifest: &instrumented.project.manifest,
1303 probes: &instrumented.project.probes,
1304 evidence: &evidence,
1305 outcomes: &outcomes,
1306 run_id: &request.run_id,
1307 generated_at: &request.started_at,
1308 test_exit_code: exit_code,
1309 coverage_model: jvm_coverage_model(),
1310 })
1311 .map_err(|error| error.to_string())?;
1312 validate_frontend_report_request(&run.declaration, &run.request)
1313 .map_err(|error| error.to_string())?;
1314 let archive_path = work_directory.join("evidence.raw.gz");
1315 let raw = write_archive(
1316 crate::assertion_inputs::append(
1317 run.archive_entries().map_err(|error| error.to_string())?,
1318 &assertion_inputs,
1319 )?,
1320 &archive_path,
1321 )
1322 .map_err(|error| error.to_string())?;
1323 let evidence_publication_ms = elapsed_ms(publication_started);
1324
1325 let timings = RunTimings {
1326 initialization_ms,
1327 workspace_preparation_ms,
1328 adapter_setup_ms,
1329 instrumented_build_ms: 0.0,
1330 test_command_ms,
1331 evidence_publication_ms,
1332 };
1333 let metadata = RunMetadata {
1334 id: request.run_id.clone(),
1335 started_at: request.started_at.clone(),
1336 duration_ms: elapsed_ms(total_started),
1337 command: request.command.clone(),
1338 test_exit_code: Some(exit_code),
1339 integrity,
1340 raw_evidence: RawEvidenceMetadata {
1341 schema_version: raw.schema_version,
1342 format: raw.format.into(),
1343 file: raw.file.into(),
1344 files: raw.files,
1345 uncompressed_bytes: raw.uncompressed_bytes,
1346 compressed_bytes: raw.compressed_bytes,
1347 },
1348 isolated_build: Some(true),
1349 instrumented_build_cache: None,
1350 timings: Some(timings),
1351 merged: None,
1352 parents: None,
1353 source_roots: source_roots.clone(),
1354 };
1355 let run_directory =
1356 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1357 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1358 Ok(DirectJvmRunResult {
1359 run_id: request.run_id.clone(),
1360 run_directory,
1361 exit_code,
1362 tests: outcomes
1363 .iter()
1364 .map(|outcome| outcome.name.as_str())
1365 .collect::<BTreeSet<_>>()
1366 .len(),
1367 source_files: instrumented.project.instrumented.len(),
1368 modules: instrumented.modules.len(),
1369 build: instrumented.build,
1370 recovered_runs,
1371 metadata,
1372 })
1373 })();
1374 if result.is_err() {
1375 let _ = remove_stored_tree_deferred(&root, &work_directory);
1376 }
1377 let release = lock.release().map_err(|error| error.to_string());
1378 match (result, release) {
1379 (Ok(result), Ok(())) => Ok(result),
1380 (Err(error), _) => Err(error),
1381 (Ok(_), Err(error)) => Err(error),
1382 }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388
1389 #[test]
1390 fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1391 assert!(asks_for_parallel_execution(Some(
1395 "junit.jupiter.execution.parallel.enabled=true\n"
1396 )));
1397 assert!(asks_for_parallel_execution(Some(
1398 " junit.jupiter.execution.parallel.enabled = True \n"
1399 )));
1400 assert!(!asks_for_parallel_execution(Some(
1401 "junit.jupiter.execution.parallel.enabled=false\n"
1402 )));
1403 assert!(!asks_for_parallel_execution(Some(
1406 "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1407 )));
1408 assert!(!asks_for_parallel_execution(None));
1409 assert!(!asks_for_parallel_execution(Some("")));
1410 }
1411
1412 #[test]
1413 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1414 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1418 junit.jupiter.execution.parallel.enabled=true\n\
1419 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1420 let updated = sequential_properties(Some(existing));
1421 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1422 assert!(
1423 !updated.contains("parallel.enabled=true"),
1424 "the project's own setting must not survive:\n{updated}"
1425 );
1426 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1428 assert!(updated.contains("displayname.generator.default"));
1429
1430 assert!(
1432 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1433 );
1434 }
1435
1436 #[test]
1437 fn gradle_is_told_to_run_the_tests_again() {
1438 let (command, note) = command_with_fresh_results(
1441 JvmBuild::Gradle,
1442 &["./gradlew".to_owned(), "test".to_owned()],
1443 );
1444 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1445 assert!(note.is_some());
1446
1447 let (command, note) = command_with_fresh_results(
1449 JvmBuild::Gradle,
1450 &[
1451 "./gradlew".to_owned(),
1452 "test".to_owned(),
1453 "--rerun".to_owned(),
1454 ],
1455 );
1456 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1457 assert!(note.is_none());
1458
1459 let (command, note) =
1461 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1462 assert_eq!(command, ["mvn", "test"]);
1463 assert!(note.is_none());
1464 }
1465
1466 #[test]
1467 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1468 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1471 assert!(
1472 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1473 "{configuration}"
1474 );
1475 assert!(configuration.contains("PROBES = 7"));
1476 assert!(configuration.contains("new int[] {2, 3}"));
1477 }
1478
1479 #[test]
1480 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1481 assert_eq!(
1484 class_of("CalculatorTest#zeroIsNamed()"),
1485 Some("CalculatorTest")
1486 );
1487 assert_eq!(
1488 class_of("CalculatorSpec#a sum adds its parts"),
1489 Some("CalculatorSpec")
1490 );
1491 assert_eq!(class_of("Standalone"), Some("Standalone"));
1492 assert_eq!(class_of(""), None);
1493 }
1494
1495 #[test]
1496 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1497 assert_eq!(
1502 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1503 .as_deref(),
1504 Some("1.10.2")
1505 );
1506 assert_eq!(
1507 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1508 .as_deref(),
1509 Some("1.13.1")
1510 );
1511 assert_eq!(
1514 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1515 None
1516 );
1517 assert_eq!(
1520 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1521 Some(DEFAULT_LAUNCHER_VERSION)
1522 );
1523 assert_eq!(
1529 launcher_version("<artifactId>parent</artifactId>").as_deref(),
1530 Some(DEFAULT_LAUNCHER_VERSION)
1531 );
1532 assert_eq!(
1533 launcher_version("").as_deref(),
1534 Some(DEFAULT_LAUNCHER_VERSION)
1535 );
1536 }
1537
1538 #[test]
1539 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1540 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";
1541 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1542 assert!(updated.contains("junit-platform-launcher"), "{updated}");
1543 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1544 assert!(
1546 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1547 "{updated}"
1548 );
1549 assert_eq!(maven_with_launcher(&updated), None);
1551
1552 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
1554 let updated = maven_with_launcher(bare).expect("a block is created");
1555 assert!(updated.contains("<dependencies>"), "{updated}");
1556 assert!(
1557 updated.find("</dependencies>") < updated.find("</project>"),
1558 "{updated}"
1559 );
1560 }
1561
1562 #[test]
1563 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1564 let groovy =
1565 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1566 let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1567 assert!(
1568 updated
1569 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1570 "{updated}"
1571 );
1572 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1574 assert_eq!(gradle_with_launcher(&updated, false), None);
1575
1576 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1582 let updated = gradle_with_launcher(runtime_only, false)
1583 .expect("a runtime-only declaration does not reach the compiler");
1584 assert!(
1585 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1586 "{updated}"
1587 );
1588 assert!(
1589 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1590 "the project's own declaration stays:\n{updated}"
1591 );
1592
1593 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1594 let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1595 assert!(
1596 updated.contains(
1597 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1598 ),
1599 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1600 );
1601 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1602 }
1603
1604 #[test]
1605 fn only_the_listeners_a_project_can_compile_are_written() {
1606 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1610 assert!(junit.platform && !junit.testng);
1611
1612 let testng = frameworks("<artifactId>testng</artifactId>");
1613 assert!(testng.testng && !testng.platform);
1614
1615 let both = frameworks("testng ... junit-jupiter");
1618 assert!(both.platform && both.testng);
1619
1620 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1623 assert!(frameworks("org.spockframework:spock-core").platform);
1624
1625 let unknown = frameworks("<artifactId>demo</artifactId>");
1628 assert!(unknown.platform && !unknown.testng);
1629 }
1630
1631 #[test]
1632 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1633 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1639 assert!(four.junit4 && !four.platform && !four.testng);
1640
1641 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1642 assert!(five.platform && !five.junit4);
1643
1644 let both = frameworks(
1647 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1648 );
1649 assert!(both.platform && !both.junit4);
1650
1651 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1653 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1654 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1655 }
1656
1657 #[test]
1658 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1659 let root = std::env::temp_dir().join(format!(
1666 "supercov-jpms-{}-{}",
1667 std::process::id(),
1668 std::time::SystemTime::now()
1669 .duration_since(std::time::UNIX_EPOCH)
1670 .unwrap()
1671 .as_nanos()
1672 ));
1673 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();
1674 for module in ["lib", "boundaries"] {
1675 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1676 write(
1677 &root.join(module).join("src/main/java/app/Api.java"),
1678 "package app;\npublic class Api { public int one() { return 1; } }\n",
1679 )
1680 .unwrap();
1681 write(
1682 &root.join(module).join("src/test/java/app/ApiTest.java"),
1683 "package app;\nclass ApiTest { void t() {} }\n",
1684 )
1685 .unwrap();
1686 }
1687 write(
1688 &root.join("boundaries/src/test/java/module-info.java"),
1689 "module app.boundaries {\n requires app.lib;\n}\n",
1690 )
1691 .unwrap();
1692
1693 let evidence = root.join("evidence");
1694 let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
1695 assert_eq!(instrumented.modular, ["boundaries"]);
1696
1697 assert!(
1699 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1700 .exists()
1701 );
1702 for name in ["SupercovListener.java", "SupercovConfig.java"] {
1703 assert!(
1704 !root
1705 .join("boundaries/src/test/java/com/supercorp/supercov")
1706 .join(name)
1707 .exists(),
1708 "{name} must not be written into a named module"
1709 );
1710 }
1711 assert!(
1712 !root
1713 .join("boundaries/src/test/resources")
1714 .join(SERVICES_FILE)
1715 .exists(),
1716 "and nothing registers a listener that is not there"
1717 );
1718 assert!(
1722 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1723 .exists()
1724 );
1725
1726 fs::remove_dir_all(root).ok();
1727 }
1728
1729 #[test]
1730 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1731 let root = std::env::temp_dir().join(format!(
1736 "supercov-catalog-{}-{}",
1737 std::process::id(),
1738 std::time::SystemTime::now()
1739 .duration_since(std::time::UNIX_EPOCH)
1740 .unwrap()
1741 .as_nanos()
1742 ));
1743 write(
1744 &root.join("gradle/libs.versions.toml"),
1745 "[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",
1746 )
1747 .unwrap();
1748 let catalog = version_catalog(&root);
1749
1750 let junit4 = frameworks(&with_catalog(
1751 "dependencies { testImplementation(libs.junit) }",
1752 &catalog,
1753 ));
1754 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1755
1756 let reflect = frameworks(&with_catalog(
1758 "dependencies { testImplementation(libs.kotlin.reflect) }",
1759 &catalog,
1760 ));
1761 assert!(!reflect.junit4, "{reflect:?}");
1762
1763 let jupiter = frameworks(&with_catalog(
1764 "dependencies { testImplementation(libs.jupiter) }",
1765 &catalog,
1766 ));
1767 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1768
1769 let bundle = frameworks(&with_catalog(
1771 "dependencies { testImplementation(libs.bundles.unit) }",
1772 &catalog,
1773 ));
1774 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1775
1776 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1778 assert_eq!(with_catalog(plain, &catalog), plain);
1779
1780 fs::remove_dir_all(root).ok();
1781 }
1782
1783 #[test]
1784 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1785 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";
1791 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1792 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1793 let managed_end = updated
1794 .find("</dependencyManagement>")
1795 .expect("managed block");
1796 let build_start = updated.find("<build>").expect("build block");
1797 assert!(
1798 at > managed_end,
1799 "not among the managed versions:\n{updated}"
1800 );
1801 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1802 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1805 }
1806
1807 #[test]
1808 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1809 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";
1815 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1816 assert!(
1817 updated.contains("<failOnWarning>false</failOnWarning>"),
1818 "{updated}"
1819 );
1820 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1821 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1824
1825 assert_eq!(without_warnings_as_errors("<project></project>"), None);
1827
1828 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1831 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1832 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1833
1834 let only_analyser =
1835 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1836 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1837 assert!(!updated.contains("ErrorProne"), "{updated}");
1838 }
1839
1840 #[test]
1841 fn vintage_carries_the_engine_version_not_the_platform_one() {
1842 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1846 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1847
1848 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";
1849 let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1850 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1851 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1852 assert!(
1853 updated.contains("<groupId>org.junit.vintage</groupId>"),
1854 "{updated}"
1855 );
1856 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1859 assert!(
1860 updated.contains(&format!(
1861 "<version>{}</version>",
1862 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1863 )),
1864 "{updated}"
1865 );
1866
1867 assert_eq!(maven_with_vintage(&updated), None);
1869 }
1870}