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, note_kept_evidence, publish_run,
43 recover_abandoned_runs, 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 measured = std::cell::Cell::new(false);
1065 let result = (|| {
1066 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1067 .map_err(|error| error.to_string())?;
1068 if !recovered_runs.is_empty() {
1069 writeln!(
1070 diagnostics,
1071 "[supercov] recovered abandoned run(s): {}",
1072 recovered_runs.join(", ")
1073 )
1074 .map_err(|error| error.to_string())?;
1075 }
1076
1077 let adapter_started = Instant::now();
1078 let files = crate::jvm_project::discover_jvm_files(&root)?;
1079 let source_roots = crate::source_discovery::configured_source_roots(
1080 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1081 );
1082 let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1083 crate::source_discovery::fold_roots_into_configuration(
1084 &mut integrity_inputs.execution_configuration,
1085 source_roots.as_deref(),
1086 );
1087 let assertion_inputs =
1088 crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1089 let integrity = create_explicit_run_integrity(
1090 &root,
1091 &integrity_inputs,
1092 &FrontendIntegrityInputs::embedded_jvm(),
1093 )
1094 .map_err(|error| error.to_string())?;
1095
1096 let workspace_started = Instant::now();
1097 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1098 let workspace =
1099 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1100 let evidence_directory = work_directory.join("jvm");
1101 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1102 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1103 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1104 &root, &workspace, &ambient,
1105 )
1106 .map_err(|error| error.to_string())?;
1107 let instrumented = instrument_workspace(
1108 &workspace,
1109 &evidence_directory,
1110 request.exact_attribution,
1111 roots.as_ref(),
1112 )?;
1113 let workspace_preparation_ms = elapsed_ms(workspace_started);
1114 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1115 writeln!(
1116 diagnostics,
1117 "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1118 match instrumented.build {
1119 JvmBuild::Maven => "a Maven project",
1120 JvmBuild::Gradle => "a Gradle project",
1121 JvmBuild::Plain => "Java/Kotlin sources",
1122 },
1123 instrumented.project.instrumented.len(),
1124 workspace.display()
1125 )
1126 .map_err(|error| error.to_string())?;
1127 if let Some(build_file) = instrumented.added_launcher {
1128 writeln!(
1129 diagnostics,
1130 "[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."
1131 )
1132 .map_err(|error| error.to_string())?;
1133 }
1134 if !instrumented.serialised.is_empty() {
1135 writeln!(
1136 diagnostics,
1137 "[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.",
1138 instrumented
1139 .serialised
1140 .iter()
1141 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1142 .collect::<Vec<_>>()
1143 .join(", ")
1144 )
1145 .map_err(|error| error.to_string())?;
1146 }
1147 if !instrumented.concurrent.is_empty() {
1148 writeln!(
1149 diagnostics,
1150 "[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.",
1151 instrumented
1152 .concurrent
1153 .iter()
1154 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1155 .collect::<Vec<_>>()
1156 .join(", ")
1157 )
1158 .map_err(|error| error.to_string())?;
1159 }
1160 if !instrumented.relaxed.is_empty() {
1161 writeln!(
1162 diagnostics,
1163 "[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.",
1164 instrumented.relaxed.join("; ")
1165 )
1166 .map_err(|error| error.to_string())?;
1167 }
1168 if instrumented.added_vintage {
1169 writeln!(
1170 diagnostics,
1171 "[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."
1172 )
1173 .map_err(|error| error.to_string())?;
1174 }
1175 if !instrumented.modular.is_empty() {
1176 writeln!(
1177 diagnostics,
1178 "[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.",
1179 instrumented.modular.len(),
1180 instrumented.modular.join(", ")
1181 )
1182 .map_err(|error| error.to_string())?;
1183 }
1184 if !instrumented.unmeasurable.is_empty() {
1185 writeln!(
1186 diagnostics,
1187 "[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.",
1188 instrumented.unmeasurable.len(),
1189 instrumented.unmeasurable.join(", ")
1190 )
1191 .map_err(|error| error.to_string())?;
1192 }
1193 for (file, reason) in &instrumented.project.unparseable {
1194 writeln!(
1195 diagnostics,
1196 "[supercov] could not parse {file}: {reason}; it carries no obligations"
1197 )
1198 .map_err(|error| error.to_string())?;
1199 }
1200
1201 let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1202 if let Some(note) = note {
1203 writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1204 }
1205 let test_started = Instant::now();
1206 let plan = ExecutionPlan {
1207 preparation: Vec::new(),
1208 test: ExecutionPhase {
1209 name: "test".into(),
1210 kind: PhaseKind::Test,
1211 command: CommandSpec {
1212 program: command[0].clone().into(),
1213 arguments: command[1..].iter().map(OsString::from).collect(),
1214 cwd: workspace.clone(),
1215 environment: None,
1216 captured_output: None,
1217 },
1218 },
1219 };
1220 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1221 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1222 .map_err(|error| error.to_string())?;
1223 let test_command_ms = elapsed_ms(test_started);
1224 if let Some(signal) = execution.interrupted_signal {
1225 return Err(format!(
1226 "the test command was interrupted by {signal:?}; no run was published"
1227 ));
1228 }
1229 let exit_code = execution.exit_code;
1230
1231 let publication_started = Instant::now();
1232 let mut parts = Vec::new();
1234 let mut outcomes = Vec::new();
1235 let mut silent = Vec::new();
1236 for module in instrumented
1237 .modules
1238 .iter()
1239 .filter(|module| module.has_tests)
1240 {
1241 let mut written = fs::read_dir(&module.evidence)
1243 .map(|entries| {
1244 entries
1245 .flatten()
1246 .map(|entry| entry.path())
1247 .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1248 .collect::<Vec<_>>()
1249 })
1250 .unwrap_or_default();
1251 written.sort();
1252 if written.is_empty() {
1253 silent.push(module.directory.clone());
1254 continue;
1255 }
1256 let mut forked = Vec::new();
1257 for path in &written {
1258 let bytes =
1259 fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1260 forked.push(
1261 read_evidence(&bytes)
1262 .map_err(|error| format!("{}: {error}", path.display()))?,
1263 );
1264 }
1265 let evidence = merge_evidence(forked);
1266 for test in &evidence.tests {
1267 outcomes.push(OwnedTestOutcome {
1268 name: test.name.clone(),
1269 runner: test.runner.clone(),
1270 package: module.directory.clone(),
1274 file: class_of(&test.name)
1275 .and_then(|class| instrumented.declared_in.get(class))
1276 .cloned(),
1277 status: test.status.clone(),
1278 attributed: !test.unattributed,
1284 });
1285 }
1286 parts.push(evidence);
1287 }
1288 if !silent.is_empty() {
1289 writeln!(
1290 diagnostics,
1291 "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1292 silent.len(),
1293 silent.join(", ")
1294 )
1295 .map_err(|error| error.to_string())?;
1296 }
1297 if outcomes.is_empty() {
1298 return Err(format!(
1299 "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."
1300 ));
1301 }
1302 let evidence = merge_evidence(parts);
1303 measured.set(true);
1304 let run = build_frontend_run(OwnedRunInputs {
1305 declaration: jvm_declaration(),
1306 environment: "jvm",
1307 manifest: &instrumented.project.manifest,
1308 probes: &instrumented.project.probes,
1309 evidence: &evidence,
1310 outcomes: &outcomes,
1311 run_id: &request.run_id,
1312 generated_at: &request.started_at,
1313 test_exit_code: exit_code,
1314 coverage_model: jvm_coverage_model(),
1315 })
1316 .map_err(|error| error.to_string())?;
1317 validate_frontend_report_request(&run.declaration, &run.request)
1318 .map_err(|error| error.to_string())?;
1319 let archive_path = work_directory.join("evidence.raw.gz");
1320 let raw = write_archive(
1321 crate::assertion_inputs::append(
1322 run.archive_entries().map_err(|error| error.to_string())?,
1323 &assertion_inputs,
1324 )?,
1325 &archive_path,
1326 )
1327 .map_err(|error| error.to_string())?;
1328 let evidence_publication_ms = elapsed_ms(publication_started);
1329
1330 let timings = RunTimings {
1331 initialization_ms,
1332 workspace_preparation_ms,
1333 adapter_setup_ms,
1334 instrumented_build_ms: 0.0,
1335 test_command_ms,
1336 evidence_publication_ms,
1337 };
1338 let metadata = RunMetadata {
1339 id: request.run_id.clone(),
1340 started_at: request.started_at.clone(),
1341 duration_ms: elapsed_ms(total_started),
1342 command: request.command.clone(),
1343 test_exit_code: Some(exit_code),
1344 integrity,
1345 raw_evidence: RawEvidenceMetadata {
1346 schema_version: raw.schema_version,
1347 format: raw.format.into(),
1348 file: raw.file.into(),
1349 files: raw.files,
1350 uncompressed_bytes: raw.uncompressed_bytes,
1351 compressed_bytes: raw.compressed_bytes,
1352 },
1353 isolated_build: Some(true),
1354 instrumented_build_cache: None,
1355 timings: Some(timings),
1356 merged: None,
1357 parents: None,
1358 source_roots: source_roots.clone(),
1359 };
1360 let run_directory =
1361 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1362 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1363 Ok(DirectJvmRunResult {
1364 run_id: request.run_id.clone(),
1365 run_directory,
1366 exit_code,
1367 tests: outcomes
1368 .iter()
1369 .map(|outcome| outcome.name.as_str())
1370 .collect::<BTreeSet<_>>()
1371 .len(),
1372 source_files: instrumented.project.instrumented.len(),
1373 modules: instrumented.modules.len(),
1374 build: instrumented.build,
1375 recovered_runs,
1376 metadata,
1377 })
1378 })();
1379 let result = result.map_err(|error| {
1380 if !measured.get() {
1381 return error;
1382 }
1383 note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1384 });
1385 if result.is_err() {
1386 let _ = remove_stored_tree_deferred(&root, &work_directory);
1387 }
1388 let release = lock.release().map_err(|error| error.to_string());
1389 match (result, release) {
1390 (Ok(result), Ok(())) => Ok(result),
1391 (Err(error), _) => Err(error),
1392 (Ok(_), Err(error)) => Err(error),
1393 }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::*;
1399
1400 #[test]
1401 fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1402 assert!(asks_for_parallel_execution(Some(
1406 "junit.jupiter.execution.parallel.enabled=true\n"
1407 )));
1408 assert!(asks_for_parallel_execution(Some(
1409 " junit.jupiter.execution.parallel.enabled = True \n"
1410 )));
1411 assert!(!asks_for_parallel_execution(Some(
1412 "junit.jupiter.execution.parallel.enabled=false\n"
1413 )));
1414 assert!(!asks_for_parallel_execution(Some(
1417 "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1418 )));
1419 assert!(!asks_for_parallel_execution(None));
1420 assert!(!asks_for_parallel_execution(Some("")));
1421 }
1422
1423 #[test]
1424 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1425 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1429 junit.jupiter.execution.parallel.enabled=true\n\
1430 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1431 let updated = sequential_properties(Some(existing));
1432 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1433 assert!(
1434 !updated.contains("parallel.enabled=true"),
1435 "the project's own setting must not survive:\n{updated}"
1436 );
1437 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1439 assert!(updated.contains("displayname.generator.default"));
1440
1441 assert!(
1443 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1444 );
1445 }
1446
1447 #[test]
1448 fn gradle_is_told_to_run_the_tests_again() {
1449 let (command, note) = command_with_fresh_results(
1452 JvmBuild::Gradle,
1453 &["./gradlew".to_owned(), "test".to_owned()],
1454 );
1455 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1456 assert!(note.is_some());
1457
1458 let (command, note) = command_with_fresh_results(
1460 JvmBuild::Gradle,
1461 &[
1462 "./gradlew".to_owned(),
1463 "test".to_owned(),
1464 "--rerun".to_owned(),
1465 ],
1466 );
1467 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1468 assert!(note.is_none());
1469
1470 let (command, note) =
1472 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1473 assert_eq!(command, ["mvn", "test"]);
1474 assert!(note.is_none());
1475 }
1476
1477 #[test]
1478 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1479 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1482 assert!(
1483 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1484 "{configuration}"
1485 );
1486 assert!(configuration.contains("PROBES = 7"));
1487 assert!(configuration.contains("new int[] {2, 3}"));
1488 }
1489
1490 #[test]
1491 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1492 assert_eq!(
1495 class_of("CalculatorTest#zeroIsNamed()"),
1496 Some("CalculatorTest")
1497 );
1498 assert_eq!(
1499 class_of("CalculatorSpec#a sum adds its parts"),
1500 Some("CalculatorSpec")
1501 );
1502 assert_eq!(class_of("Standalone"), Some("Standalone"));
1503 assert_eq!(class_of(""), None);
1504 }
1505
1506 #[test]
1507 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1508 assert_eq!(
1513 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1514 .as_deref(),
1515 Some("1.10.2")
1516 );
1517 assert_eq!(
1518 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1519 .as_deref(),
1520 Some("1.13.1")
1521 );
1522 assert_eq!(
1525 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1526 None
1527 );
1528 assert_eq!(
1531 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1532 Some(DEFAULT_LAUNCHER_VERSION)
1533 );
1534 assert_eq!(
1540 launcher_version("<artifactId>parent</artifactId>").as_deref(),
1541 Some(DEFAULT_LAUNCHER_VERSION)
1542 );
1543 assert_eq!(
1544 launcher_version("").as_deref(),
1545 Some(DEFAULT_LAUNCHER_VERSION)
1546 );
1547 }
1548
1549 #[test]
1550 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1551 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";
1552 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1553 assert!(updated.contains("junit-platform-launcher"), "{updated}");
1554 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1555 assert!(
1557 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1558 "{updated}"
1559 );
1560 assert_eq!(maven_with_launcher(&updated), None);
1562
1563 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
1565 let updated = maven_with_launcher(bare).expect("a block is created");
1566 assert!(updated.contains("<dependencies>"), "{updated}");
1567 assert!(
1568 updated.find("</dependencies>") < updated.find("</project>"),
1569 "{updated}"
1570 );
1571 }
1572
1573 #[test]
1574 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1575 let groovy =
1576 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1577 let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1578 assert!(
1579 updated
1580 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1581 "{updated}"
1582 );
1583 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1585 assert_eq!(gradle_with_launcher(&updated, false), None);
1586
1587 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1593 let updated = gradle_with_launcher(runtime_only, false)
1594 .expect("a runtime-only declaration does not reach the compiler");
1595 assert!(
1596 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1597 "{updated}"
1598 );
1599 assert!(
1600 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1601 "the project's own declaration stays:\n{updated}"
1602 );
1603
1604 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1605 let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1606 assert!(
1607 updated.contains(
1608 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1609 ),
1610 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1611 );
1612 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1613 }
1614
1615 #[test]
1616 fn only_the_listeners_a_project_can_compile_are_written() {
1617 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1621 assert!(junit.platform && !junit.testng);
1622
1623 let testng = frameworks("<artifactId>testng</artifactId>");
1624 assert!(testng.testng && !testng.platform);
1625
1626 let both = frameworks("testng ... junit-jupiter");
1629 assert!(both.platform && both.testng);
1630
1631 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1634 assert!(frameworks("org.spockframework:spock-core").platform);
1635
1636 let unknown = frameworks("<artifactId>demo</artifactId>");
1639 assert!(unknown.platform && !unknown.testng);
1640 }
1641
1642 #[test]
1643 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1644 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1650 assert!(four.junit4 && !four.platform && !four.testng);
1651
1652 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1653 assert!(five.platform && !five.junit4);
1654
1655 let both = frameworks(
1658 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1659 );
1660 assert!(both.platform && !both.junit4);
1661
1662 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1664 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1665 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1666 }
1667
1668 #[test]
1669 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1670 let root = std::env::temp_dir().join(format!(
1677 "supercov-jpms-{}-{}",
1678 std::process::id(),
1679 std::time::SystemTime::now()
1680 .duration_since(std::time::UNIX_EPOCH)
1681 .unwrap()
1682 .as_nanos()
1683 ));
1684 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();
1685 for module in ["lib", "boundaries"] {
1686 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1687 write(
1688 &root.join(module).join("src/main/java/app/Api.java"),
1689 "package app;\npublic class Api { public int one() { return 1; } }\n",
1690 )
1691 .unwrap();
1692 write(
1693 &root.join(module).join("src/test/java/app/ApiTest.java"),
1694 "package app;\nclass ApiTest { void t() {} }\n",
1695 )
1696 .unwrap();
1697 }
1698 write(
1699 &root.join("boundaries/src/test/java/module-info.java"),
1700 "module app.boundaries {\n requires app.lib;\n}\n",
1701 )
1702 .unwrap();
1703
1704 let evidence = root.join("evidence");
1705 let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
1706 assert_eq!(instrumented.modular, ["boundaries"]);
1707
1708 assert!(
1710 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1711 .exists()
1712 );
1713 for name in ["SupercovListener.java", "SupercovConfig.java"] {
1714 assert!(
1715 !root
1716 .join("boundaries/src/test/java/com/supercorp/supercov")
1717 .join(name)
1718 .exists(),
1719 "{name} must not be written into a named module"
1720 );
1721 }
1722 assert!(
1723 !root
1724 .join("boundaries/src/test/resources")
1725 .join(SERVICES_FILE)
1726 .exists(),
1727 "and nothing registers a listener that is not there"
1728 );
1729 assert!(
1733 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1734 .exists()
1735 );
1736
1737 fs::remove_dir_all(root).ok();
1738 }
1739
1740 #[test]
1741 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1742 let root = std::env::temp_dir().join(format!(
1747 "supercov-catalog-{}-{}",
1748 std::process::id(),
1749 std::time::SystemTime::now()
1750 .duration_since(std::time::UNIX_EPOCH)
1751 .unwrap()
1752 .as_nanos()
1753 ));
1754 write(
1755 &root.join("gradle/libs.versions.toml"),
1756 "[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",
1757 )
1758 .unwrap();
1759 let catalog = version_catalog(&root);
1760
1761 let junit4 = frameworks(&with_catalog(
1762 "dependencies { testImplementation(libs.junit) }",
1763 &catalog,
1764 ));
1765 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1766
1767 let reflect = frameworks(&with_catalog(
1769 "dependencies { testImplementation(libs.kotlin.reflect) }",
1770 &catalog,
1771 ));
1772 assert!(!reflect.junit4, "{reflect:?}");
1773
1774 let jupiter = frameworks(&with_catalog(
1775 "dependencies { testImplementation(libs.jupiter) }",
1776 &catalog,
1777 ));
1778 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1779
1780 let bundle = frameworks(&with_catalog(
1782 "dependencies { testImplementation(libs.bundles.unit) }",
1783 &catalog,
1784 ));
1785 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1786
1787 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1789 assert_eq!(with_catalog(plain, &catalog), plain);
1790
1791 fs::remove_dir_all(root).ok();
1792 }
1793
1794 #[test]
1795 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1796 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";
1802 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1803 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1804 let managed_end = updated
1805 .find("</dependencyManagement>")
1806 .expect("managed block");
1807 let build_start = updated.find("<build>").expect("build block");
1808 assert!(
1809 at > managed_end,
1810 "not among the managed versions:\n{updated}"
1811 );
1812 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1813 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1816 }
1817
1818 #[test]
1819 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1820 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";
1826 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1827 assert!(
1828 updated.contains("<failOnWarning>false</failOnWarning>"),
1829 "{updated}"
1830 );
1831 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1832 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1835
1836 assert_eq!(without_warnings_as_errors("<project></project>"), None);
1838
1839 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1842 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1843 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1844
1845 let only_analyser =
1846 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1847 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1848 assert!(!updated.contains("ErrorProne"), "{updated}");
1849 }
1850
1851 #[test]
1852 fn vintage_carries_the_engine_version_not_the_platform_one() {
1853 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1857 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1858
1859 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";
1860 let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1861 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1862 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1863 assert!(
1864 updated.contains("<groupId>org.junit.vintage</groupId>"),
1865 "{updated}"
1866 );
1867 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1870 assert!(
1871 updated.contains(&format!(
1872 "<version>{}</version>",
1873 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1874 )),
1875 "{updated}"
1876 );
1877
1878 assert_eq!(maven_with_vintage(&updated), None);
1880 }
1881}