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) -> Result<InstrumentedWorkspace, String> {
717 let project = prepare_jvm_project(workspace)?;
718 let build = detect_build(workspace);
719
720 for (relative, instrumented) in &project.instrumented {
721 write(&workspace.join(relative), instrumented)?;
722 }
723
724 let probe_count = project
725 .probes
726 .keys()
727 .max()
728 .map_or(0, |highest| *highest as usize + 1);
729
730 let catalog = version_catalog(workspace);
736 let frameworks_of = |directory: &str| -> Frameworks {
737 let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
738 let mut text = names
739 .iter()
740 .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
741 .unwrap_or_default();
742 for name in names {
743 if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
744 text.push('\n');
745 text.push_str(&own);
746 break;
747 }
748 }
749 frameworks(&with_catalog(&text, &catalog))
750 };
751 let mut unmeasurable: Vec<String> = Vec::new();
752 let mut modular: Vec<String> = Vec::new();
753
754 let mut modules: BTreeMap<String, bool> = BTreeMap::new();
757 for (relative, _) in &project.files.sources {
758 modules.entry(module_of(relative)).or_insert(false);
759 }
760 for (relative, _) in &project.files.tests {
761 *modules.entry(module_of(relative)).or_default() = true;
762 }
763 for module in modules.keys().cloned().collect::<Vec<_>>() {
770 if workspace.join(&module).join("src/test").is_dir() {
771 modules.insert(module, true);
772 }
773 }
774 if modules.is_empty() {
775 modules.insert(".".to_owned(), true);
776 }
777
778 let modules = modules
779 .into_iter()
780 .map(|(directory, has_tests)| JvmModule {
781 evidence: evidence_directory.join(
782 directory
783 .chars()
784 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
785 .collect::<String>(),
786 ),
787 directory,
788 has_tests,
789 })
790 .collect::<Vec<_>>();
791
792 for module in &modules {
793 let frameworks = frameworks_of(&module.directory);
794 let at = |source_set: &str| {
795 workspace
796 .join(&module.directory)
797 .join(source_root(source_set))
798 .join(PACKAGE_DIRECTORY)
799 };
800 write(
805 &at("main").join("Supercov.java"),
806 &runtime_source(probe_count),
807 )?;
808 if !module.has_tests {
809 continue;
810 }
811 if frameworks.junit4 && build != JvmBuild::Maven {
812 unmeasurable.push(module.directory.clone());
816 continue;
817 }
818 if workspace
828 .join(&module.directory)
829 .join(source_root("test"))
830 .join("module-info.java")
831 .exists()
832 {
833 modular.push(module.directory.clone());
834 continue;
835 }
836 let test = at("test");
839 write(
840 &test.join("SupercovConfig.java"),
841 &configuration(probe_count, &project.decision_widths, &module.evidence),
842 )?;
843 if frameworks.platform || frameworks.junit4 {
844 write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
845 }
846 if frameworks.testng {
847 write(
848 &test.join("SupercovTestNGListener.java"),
849 TESTNG_LISTENER_SOURCE,
850 )?;
851 }
852 }
853
854 let mut relaxed: Vec<&'static str> = Vec::new();
857 for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
858 let path = workspace.join(name);
859 let Ok(existing) = fs::read_to_string(&path) else {
860 continue;
861 };
862 let Some(updated) = without_warnings_as_errors(&existing) else {
863 continue;
864 };
865 if !relaxed.contains(&"stopped the build failing on warnings")
869 && updated.contains("<failOnWarning>false</failOnWarning>")
870 != existing.contains("<failOnWarning>false</failOnWarning>")
871 || existing.contains("-Werror") && !updated.contains("-Werror")
872 {
873 relaxed.push("stopped the build failing on warnings");
874 }
875 if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
876 relaxed.push("switched Error Prone off");
877 }
878 write(&path, &updated)?;
879 }
880 relaxed.dedup();
881
882 let mut added_launcher = None;
887 let mut serialised: BTreeSet<String> = BTreeSet::new();
888 let mut concurrent: BTreeSet<String> = BTreeSet::new();
889 let mut added_vintage = false;
890 match build {
891 JvmBuild::Maven => {
895 for module in modules
896 .iter()
897 .filter(|module| {
898 module.has_tests
899 && !unmeasurable.contains(&module.directory)
900 && !modular.contains(&module.directory)
901 })
902 .filter(|module| {
906 let frameworks = frameworks_of(&module.directory);
907 frameworks.platform || frameworks.junit4
908 })
909 {
910 let pom = workspace.join(&module.directory).join("pom.xml");
911 let Ok(existing) = fs::read_to_string(&pom) else {
912 continue;
913 };
914 let updated = if frameworks_of(&module.directory).junit4 {
918 added_vintage = true;
919 maven_with_vintage(&existing)
920 } else {
921 maven_with_launcher(&existing)
922 };
923 if let Some(updated) = updated {
924 write(&pom, &updated)?;
925 added_launcher = Some("pom.xml");
926 }
927 }
928 }
929 JvmBuild::Gradle
930 if !modules
931 .iter()
932 .any(|module| module.has_tests && frameworks_of(&module.directory).platform) => {}
933 JvmBuild::Gradle => {
934 for name in ["build.gradle.kts", "build.gradle"] {
935 let path = workspace.join(name);
936 let Ok(existing) = fs::read_to_string(&path) else {
937 continue;
938 };
939 if let Some(updated) = gradle_with_launcher(&existing, name.ends_with(".kts")) {
940 write(&path, &updated)?;
941 added_launcher = Some(if name.ends_with(".kts") {
942 "build.gradle.kts"
943 } else {
944 "build.gradle"
945 });
946 }
947 break;
948 }
949 }
950 JvmBuild::Plain => {}
953 }
954
955 for module in modules.iter().filter(|module| {
956 module.has_tests
957 && !unmeasurable.contains(&module.directory)
958 && !modular.contains(&module.directory)
959 }) {
960 let frameworks = frameworks_of(&module.directory);
961 let resources = workspace.join(&module.directory).join("src/test/resources");
962 if frameworks.platform || frameworks.junit4 {
963 write(
964 &resources.join(SERVICES_FILE),
965 &format!("{LISTENER_CLASS}\n"),
966 )?;
967 let properties = resources.join("junit-platform.properties");
968 let existing = fs::read_to_string(&properties).ok();
969 if !exact_attribution {
970 if asks_for_parallel_execution(existing.as_deref()) {
976 concurrent.insert(module.directory.clone());
977 }
978 continue;
979 }
980 if asks_for_parallel_execution(existing.as_deref()) {
987 serialised.insert(module.directory.clone());
988 }
989 write(&properties, &sequential_properties(existing.as_deref()))?;
990 }
991 if frameworks.testng {
992 write(
993 &resources.join(TESTNG_SERVICES_FILE),
994 &format!("{TESTNG_LISTENER_CLASS}\n"),
995 )?;
996 }
997 }
998
999 let mut declared_in = BTreeMap::new();
1003 for (relative, _) in &project.files.tests {
1004 if let Some(stem) = relative
1005 .rsplit('/')
1006 .next()
1007 .and_then(|name| name.split('.').next())
1008 {
1009 declared_in.insert(stem.to_owned(), relative.clone());
1010 }
1011 }
1012
1013 Ok(InstrumentedWorkspace {
1014 project,
1015 build,
1016 modules,
1017 declared_in,
1018 added_launcher,
1019 serialised,
1020 concurrent,
1021 added_vintage,
1022 relaxed,
1023 unmeasurable,
1024 modular,
1025 })
1026}
1027
1028pub fn current_jvm_integrity(
1030 root: &Path,
1031 command: &[String],
1032) -> Result<crate::run_store::RunIntegrity, String> {
1033 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1034 let files = crate::jvm_project::discover_jvm_files(&root)?;
1035 create_explicit_run_integrity(
1036 &root,
1037 &jvm_integrity_inputs(&files, command),
1038 &FrontendIntegrityInputs::embedded_jvm(),
1039 )
1040 .map_err(|error| error.to_string())
1041}
1042
1043pub fn run_direct_jvm(
1044 request: &DirectJvmRunRequest,
1045 diagnostics: &mut dyn Write,
1046) -> Result<DirectJvmRunResult, String> {
1047 if request.command.is_empty() {
1048 return Err("test command must not be empty".into());
1049 }
1050 let total_started = Instant::now();
1051 let initialization_started = Instant::now();
1052 let root = canonicalize_simplified(&request.root)
1053 .map_err(|error| format!("{}: {error}", request.root.display()))?;
1054 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1055 .map_err(|error| error.to_string())?;
1056 let initialization_ms = elapsed_ms(initialization_started);
1057 let work_directory = root.join(".supercov/work").join(&request.run_id);
1058 let result = (|| {
1059 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1060 .map_err(|error| error.to_string())?;
1061 if !recovered_runs.is_empty() {
1062 writeln!(
1063 diagnostics,
1064 "[supercov] recovered abandoned run(s): {}",
1065 recovered_runs.join(", ")
1066 )
1067 .map_err(|error| error.to_string())?;
1068 }
1069
1070 let adapter_started = Instant::now();
1071 let files = crate::jvm_project::discover_jvm_files(&root)?;
1072 let integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1073 let assertion_inputs =
1074 crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1075 let integrity = create_explicit_run_integrity(
1076 &root,
1077 &integrity_inputs,
1078 &FrontendIntegrityInputs::embedded_jvm(),
1079 )
1080 .map_err(|error| error.to_string())?;
1081
1082 let workspace_started = Instant::now();
1083 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1084 let workspace =
1085 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1086 let evidence_directory = work_directory.join("jvm");
1087 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1088 let instrumented =
1089 instrument_workspace(&workspace, &evidence_directory, request.exact_attribution)?;
1090 let workspace_preparation_ms = elapsed_ms(workspace_started);
1091 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1092 writeln!(
1093 diagnostics,
1094 "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1095 match instrumented.build {
1096 JvmBuild::Maven => "a Maven project",
1097 JvmBuild::Gradle => "a Gradle project",
1098 JvmBuild::Plain => "Java/Kotlin sources",
1099 },
1100 instrumented.project.instrumented.len(),
1101 workspace.display()
1102 )
1103 .map_err(|error| error.to_string())?;
1104 if let Some(build_file) = instrumented.added_launcher {
1105 writeln!(
1106 diagnostics,
1107 "[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."
1108 )
1109 .map_err(|error| error.to_string())?;
1110 }
1111 if !instrumented.serialised.is_empty() {
1112 writeln!(
1113 diagnostics,
1114 "[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.",
1115 instrumented
1116 .serialised
1117 .iter()
1118 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1119 .collect::<Vec<_>>()
1120 .join(", ")
1121 )
1122 .map_err(|error| error.to_string())?;
1123 }
1124 if !instrumented.concurrent.is_empty() {
1125 writeln!(
1126 diagnostics,
1127 "[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.",
1128 instrumented
1129 .concurrent
1130 .iter()
1131 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1132 .collect::<Vec<_>>()
1133 .join(", ")
1134 )
1135 .map_err(|error| error.to_string())?;
1136 }
1137 if !instrumented.relaxed.is_empty() {
1138 writeln!(
1139 diagnostics,
1140 "[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.",
1141 instrumented.relaxed.join("; ")
1142 )
1143 .map_err(|error| error.to_string())?;
1144 }
1145 if instrumented.added_vintage {
1146 writeln!(
1147 diagnostics,
1148 "[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."
1149 )
1150 .map_err(|error| error.to_string())?;
1151 }
1152 if !instrumented.modular.is_empty() {
1153 writeln!(
1154 diagnostics,
1155 "[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.",
1156 instrumented.modular.len(),
1157 instrumented.modular.join(", ")
1158 )
1159 .map_err(|error| error.to_string())?;
1160 }
1161 if !instrumented.unmeasurable.is_empty() {
1162 writeln!(
1163 diagnostics,
1164 "[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.",
1165 instrumented.unmeasurable.len(),
1166 instrumented.unmeasurable.join(", ")
1167 )
1168 .map_err(|error| error.to_string())?;
1169 }
1170 for (file, reason) in &instrumented.project.unparseable {
1171 writeln!(
1172 diagnostics,
1173 "[supercov] could not parse {file}: {reason}; it carries no obligations"
1174 )
1175 .map_err(|error| error.to_string())?;
1176 }
1177
1178 let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1179 if let Some(note) = note {
1180 writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1181 }
1182 let test_started = Instant::now();
1183 let plan = ExecutionPlan {
1184 preparation: Vec::new(),
1185 test: ExecutionPhase {
1186 name: "test".into(),
1187 kind: PhaseKind::Test,
1188 command: CommandSpec {
1189 program: command[0].clone().into(),
1190 arguments: command[1..].iter().map(OsString::from).collect(),
1191 cwd: workspace.clone(),
1192 environment: None,
1193 captured_output: None,
1194 },
1195 },
1196 };
1197 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1198 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1199 .map_err(|error| error.to_string())?;
1200 let test_command_ms = elapsed_ms(test_started);
1201 if let Some(signal) = execution.interrupted_signal {
1202 return Err(format!(
1203 "the test command was interrupted by {signal:?}; no run was published"
1204 ));
1205 }
1206 let exit_code = execution.exit_code;
1207
1208 let publication_started = Instant::now();
1209 let mut parts = Vec::new();
1211 let mut outcomes = Vec::new();
1212 let mut silent = Vec::new();
1213 for module in instrumented
1214 .modules
1215 .iter()
1216 .filter(|module| module.has_tests)
1217 {
1218 let mut written = fs::read_dir(&module.evidence)
1220 .map(|entries| {
1221 entries
1222 .flatten()
1223 .map(|entry| entry.path())
1224 .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1225 .collect::<Vec<_>>()
1226 })
1227 .unwrap_or_default();
1228 written.sort();
1229 if written.is_empty() {
1230 silent.push(module.directory.clone());
1231 continue;
1232 }
1233 let mut forked = Vec::new();
1234 for path in &written {
1235 let bytes =
1236 fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1237 forked.push(
1238 read_evidence(&bytes)
1239 .map_err(|error| format!("{}: {error}", path.display()))?,
1240 );
1241 }
1242 let evidence = merge_evidence(forked);
1243 for test in &evidence.tests {
1244 outcomes.push(OwnedTestOutcome {
1245 name: test.name.clone(),
1246 runner: test.runner.clone(),
1247 package: module.directory.clone(),
1251 file: class_of(&test.name)
1252 .and_then(|class| instrumented.declared_in.get(class))
1253 .cloned(),
1254 status: test.status.clone(),
1255 attributed: !test.unattributed,
1261 });
1262 }
1263 parts.push(evidence);
1264 }
1265 if !silent.is_empty() {
1266 writeln!(
1267 diagnostics,
1268 "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1269 silent.len(),
1270 silent.join(", ")
1271 )
1272 .map_err(|error| error.to_string())?;
1273 }
1274 if outcomes.is_empty() {
1275 return Err(format!(
1276 "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."
1277 ));
1278 }
1279 let evidence = merge_evidence(parts);
1280 let run = build_frontend_run(OwnedRunInputs {
1281 declaration: jvm_declaration(),
1282 environment: "jvm",
1283 manifest: &instrumented.project.manifest,
1284 probes: &instrumented.project.probes,
1285 evidence: &evidence,
1286 outcomes: &outcomes,
1287 run_id: &request.run_id,
1288 generated_at: &request.started_at,
1289 test_exit_code: exit_code,
1290 coverage_model: jvm_coverage_model(),
1291 })
1292 .map_err(|error| error.to_string())?;
1293 validate_frontend_report_request(&run.declaration, &run.request)
1294 .map_err(|error| error.to_string())?;
1295 let archive_path = work_directory.join("evidence.raw.gz");
1296 let raw = write_archive(
1297 crate::assertion_inputs::append(
1298 run.archive_entries().map_err(|error| error.to_string())?,
1299 &assertion_inputs,
1300 )?,
1301 &archive_path,
1302 )
1303 .map_err(|error| error.to_string())?;
1304 let evidence_publication_ms = elapsed_ms(publication_started);
1305
1306 let timings = RunTimings {
1307 initialization_ms,
1308 workspace_preparation_ms,
1309 adapter_setup_ms,
1310 instrumented_build_ms: 0.0,
1311 test_command_ms,
1312 evidence_publication_ms,
1313 };
1314 let metadata = RunMetadata {
1315 id: request.run_id.clone(),
1316 started_at: request.started_at.clone(),
1317 duration_ms: elapsed_ms(total_started),
1318 command: request.command.clone(),
1319 test_exit_code: Some(exit_code),
1320 integrity,
1321 raw_evidence: RawEvidenceMetadata {
1322 schema_version: raw.schema_version,
1323 format: raw.format.into(),
1324 file: raw.file.into(),
1325 files: raw.files,
1326 uncompressed_bytes: raw.uncompressed_bytes,
1327 compressed_bytes: raw.compressed_bytes,
1328 },
1329 isolated_build: Some(true),
1330 instrumented_build_cache: None,
1331 timings: Some(timings),
1332 merged: None,
1333 parents: None,
1334 };
1335 let run_directory =
1336 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1337 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1338 Ok(DirectJvmRunResult {
1339 run_id: request.run_id.clone(),
1340 run_directory,
1341 exit_code,
1342 tests: outcomes
1343 .iter()
1344 .map(|outcome| outcome.name.as_str())
1345 .collect::<BTreeSet<_>>()
1346 .len(),
1347 source_files: instrumented.project.instrumented.len(),
1348 modules: instrumented.modules.len(),
1349 build: instrumented.build,
1350 recovered_runs,
1351 metadata,
1352 })
1353 })();
1354 if result.is_err() {
1355 let _ = remove_stored_tree_deferred(&root, &work_directory);
1356 }
1357 let release = lock.release().map_err(|error| error.to_string());
1358 match (result, release) {
1359 (Ok(result), Ok(())) => Ok(result),
1360 (Err(error), _) => Err(error),
1361 (Ok(_), Err(error)) => Err(error),
1362 }
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367 use super::*;
1368
1369 #[test]
1370 fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1371 assert!(asks_for_parallel_execution(Some(
1375 "junit.jupiter.execution.parallel.enabled=true\n"
1376 )));
1377 assert!(asks_for_parallel_execution(Some(
1378 " junit.jupiter.execution.parallel.enabled = True \n"
1379 )));
1380 assert!(!asks_for_parallel_execution(Some(
1381 "junit.jupiter.execution.parallel.enabled=false\n"
1382 )));
1383 assert!(!asks_for_parallel_execution(Some(
1386 "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1387 )));
1388 assert!(!asks_for_parallel_execution(None));
1389 assert!(!asks_for_parallel_execution(Some("")));
1390 }
1391
1392 #[test]
1393 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1394 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1398 junit.jupiter.execution.parallel.enabled=true\n\
1399 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1400 let updated = sequential_properties(Some(existing));
1401 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1402 assert!(
1403 !updated.contains("parallel.enabled=true"),
1404 "the project's own setting must not survive:\n{updated}"
1405 );
1406 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1408 assert!(updated.contains("displayname.generator.default"));
1409
1410 assert!(
1412 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1413 );
1414 }
1415
1416 #[test]
1417 fn gradle_is_told_to_run_the_tests_again() {
1418 let (command, note) = command_with_fresh_results(
1421 JvmBuild::Gradle,
1422 &["./gradlew".to_owned(), "test".to_owned()],
1423 );
1424 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1425 assert!(note.is_some());
1426
1427 let (command, note) = command_with_fresh_results(
1429 JvmBuild::Gradle,
1430 &[
1431 "./gradlew".to_owned(),
1432 "test".to_owned(),
1433 "--rerun".to_owned(),
1434 ],
1435 );
1436 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1437 assert!(note.is_none());
1438
1439 let (command, note) =
1441 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1442 assert_eq!(command, ["mvn", "test"]);
1443 assert!(note.is_none());
1444 }
1445
1446 #[test]
1447 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1448 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1451 assert!(
1452 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1453 "{configuration}"
1454 );
1455 assert!(configuration.contains("PROBES = 7"));
1456 assert!(configuration.contains("new int[] {2, 3}"));
1457 }
1458
1459 #[test]
1460 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1461 assert_eq!(
1464 class_of("CalculatorTest#zeroIsNamed()"),
1465 Some("CalculatorTest")
1466 );
1467 assert_eq!(
1468 class_of("CalculatorSpec#a sum adds its parts"),
1469 Some("CalculatorSpec")
1470 );
1471 assert_eq!(class_of("Standalone"), Some("Standalone"));
1472 assert_eq!(class_of(""), None);
1473 }
1474
1475 #[test]
1476 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1477 assert_eq!(
1482 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1483 .as_deref(),
1484 Some("1.10.2")
1485 );
1486 assert_eq!(
1487 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1488 .as_deref(),
1489 Some("1.13.1")
1490 );
1491 assert_eq!(
1494 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1495 None
1496 );
1497 assert_eq!(
1500 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1501 Some(DEFAULT_LAUNCHER_VERSION)
1502 );
1503 assert_eq!(
1509 launcher_version("<artifactId>parent</artifactId>").as_deref(),
1510 Some(DEFAULT_LAUNCHER_VERSION)
1511 );
1512 assert_eq!(
1513 launcher_version("").as_deref(),
1514 Some(DEFAULT_LAUNCHER_VERSION)
1515 );
1516 }
1517
1518 #[test]
1519 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1520 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";
1521 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1522 assert!(updated.contains("junit-platform-launcher"), "{updated}");
1523 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1524 assert!(
1526 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1527 "{updated}"
1528 );
1529 assert_eq!(maven_with_launcher(&updated), None);
1531
1532 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
1534 let updated = maven_with_launcher(bare).expect("a block is created");
1535 assert!(updated.contains("<dependencies>"), "{updated}");
1536 assert!(
1537 updated.find("</dependencies>") < updated.find("</project>"),
1538 "{updated}"
1539 );
1540 }
1541
1542 #[test]
1543 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1544 let groovy =
1545 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1546 let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1547 assert!(
1548 updated
1549 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1550 "{updated}"
1551 );
1552 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1554 assert_eq!(gradle_with_launcher(&updated, false), None);
1555
1556 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1562 let updated = gradle_with_launcher(runtime_only, false)
1563 .expect("a runtime-only declaration does not reach the compiler");
1564 assert!(
1565 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1566 "{updated}"
1567 );
1568 assert!(
1569 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1570 "the project's own declaration stays:\n{updated}"
1571 );
1572
1573 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1574 let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1575 assert!(
1576 updated.contains(
1577 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1578 ),
1579 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1580 );
1581 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1582 }
1583
1584 #[test]
1585 fn only_the_listeners_a_project_can_compile_are_written() {
1586 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1590 assert!(junit.platform && !junit.testng);
1591
1592 let testng = frameworks("<artifactId>testng</artifactId>");
1593 assert!(testng.testng && !testng.platform);
1594
1595 let both = frameworks("testng ... junit-jupiter");
1598 assert!(both.platform && both.testng);
1599
1600 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1603 assert!(frameworks("org.spockframework:spock-core").platform);
1604
1605 let unknown = frameworks("<artifactId>demo</artifactId>");
1608 assert!(unknown.platform && !unknown.testng);
1609 }
1610
1611 #[test]
1612 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1613 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1619 assert!(four.junit4 && !four.platform && !four.testng);
1620
1621 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1622 assert!(five.platform && !five.junit4);
1623
1624 let both = frameworks(
1627 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1628 );
1629 assert!(both.platform && !both.junit4);
1630
1631 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1633 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1634 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1635 }
1636
1637 #[test]
1638 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1639 let root = std::env::temp_dir().join(format!(
1646 "supercov-jpms-{}-{}",
1647 std::process::id(),
1648 std::time::SystemTime::now()
1649 .duration_since(std::time::UNIX_EPOCH)
1650 .unwrap()
1651 .as_nanos()
1652 ));
1653 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();
1654 for module in ["lib", "boundaries"] {
1655 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1656 write(
1657 &root.join(module).join("src/main/java/app/Api.java"),
1658 "package app;\npublic class Api { public int one() { return 1; } }\n",
1659 )
1660 .unwrap();
1661 write(
1662 &root.join(module).join("src/test/java/app/ApiTest.java"),
1663 "package app;\nclass ApiTest { void t() {} }\n",
1664 )
1665 .unwrap();
1666 }
1667 write(
1668 &root.join("boundaries/src/test/java/module-info.java"),
1669 "module app.boundaries {\n requires app.lib;\n}\n",
1670 )
1671 .unwrap();
1672
1673 let evidence = root.join("evidence");
1674 let instrumented = instrument_workspace(&root, &evidence, false).expect("instrument");
1675 assert_eq!(instrumented.modular, ["boundaries"]);
1676
1677 assert!(
1679 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1680 .exists()
1681 );
1682 for name in ["SupercovListener.java", "SupercovConfig.java"] {
1683 assert!(
1684 !root
1685 .join("boundaries/src/test/java/com/supercorp/supercov")
1686 .join(name)
1687 .exists(),
1688 "{name} must not be written into a named module"
1689 );
1690 }
1691 assert!(
1692 !root
1693 .join("boundaries/src/test/resources")
1694 .join(SERVICES_FILE)
1695 .exists(),
1696 "and nothing registers a listener that is not there"
1697 );
1698 assert!(
1702 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1703 .exists()
1704 );
1705
1706 fs::remove_dir_all(root).ok();
1707 }
1708
1709 #[test]
1710 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1711 let root = std::env::temp_dir().join(format!(
1716 "supercov-catalog-{}-{}",
1717 std::process::id(),
1718 std::time::SystemTime::now()
1719 .duration_since(std::time::UNIX_EPOCH)
1720 .unwrap()
1721 .as_nanos()
1722 ));
1723 write(
1724 &root.join("gradle/libs.versions.toml"),
1725 "[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",
1726 )
1727 .unwrap();
1728 let catalog = version_catalog(&root);
1729
1730 let junit4 = frameworks(&with_catalog(
1731 "dependencies { testImplementation(libs.junit) }",
1732 &catalog,
1733 ));
1734 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1735
1736 let reflect = frameworks(&with_catalog(
1738 "dependencies { testImplementation(libs.kotlin.reflect) }",
1739 &catalog,
1740 ));
1741 assert!(!reflect.junit4, "{reflect:?}");
1742
1743 let jupiter = frameworks(&with_catalog(
1744 "dependencies { testImplementation(libs.jupiter) }",
1745 &catalog,
1746 ));
1747 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1748
1749 let bundle = frameworks(&with_catalog(
1751 "dependencies { testImplementation(libs.bundles.unit) }",
1752 &catalog,
1753 ));
1754 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1755
1756 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1758 assert_eq!(with_catalog(plain, &catalog), plain);
1759
1760 fs::remove_dir_all(root).ok();
1761 }
1762
1763 #[test]
1764 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1765 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";
1771 let updated = maven_with_launcher(pom).expect("the launcher is missing");
1772 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1773 let managed_end = updated
1774 .find("</dependencyManagement>")
1775 .expect("managed block");
1776 let build_start = updated.find("<build>").expect("build block");
1777 assert!(
1778 at > managed_end,
1779 "not among the managed versions:\n{updated}"
1780 );
1781 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1782 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1785 }
1786
1787 #[test]
1788 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1789 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";
1795 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1796 assert!(
1797 updated.contains("<failOnWarning>false</failOnWarning>"),
1798 "{updated}"
1799 );
1800 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1801 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1804
1805 assert_eq!(without_warnings_as_errors("<project></project>"), None);
1807
1808 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1811 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1812 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1813
1814 let only_analyser =
1815 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1816 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1817 assert!(!updated.contains("ErrorProne"), "{updated}");
1818 }
1819
1820 #[test]
1821 fn vintage_carries_the_engine_version_not_the_platform_one() {
1822 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1826 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1827
1828 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";
1829 let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1830 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1831 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1832 assert!(
1833 updated.contains("<groupId>org.junit.vintage</groupId>"),
1834 "{updated}"
1835 );
1836 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1839 assert!(
1840 updated.contains(&format!(
1841 "<version>{}</version>",
1842 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1843 )),
1844 "{updated}"
1845 );
1846
1847 assert_eq!(maven_with_vintage(&updated), None);
1849 }
1850}