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
203fn named_modules(build: JvmBuild, command: &[String]) -> Option<BTreeSet<String>> {
208 let arguments = command.get(1..).unwrap_or_default();
209 match build {
210 JvmBuild::Gradle => {
211 let mut named = BTreeSet::new();
212 let mut skip = false;
213 for argument in arguments {
214 if std::mem::take(&mut skip) {
215 continue;
216 }
217 if argument.starts_with('-') {
218 skip = matches!(
221 argument.as_str(),
222 "-x" | "--exclude-task"
223 | "--tests"
224 | "-p"
225 | "--project-dir"
226 | "-c"
227 | "--settings-file"
228 | "-I"
229 | "--init-script"
230 | "-g"
231 | "--gradle-user-home"
232 );
233 continue;
234 }
235 let qualified = argument.strip_prefix(':')?;
238 let project = qualified.rsplit_once(':').map_or("", |(p, _)| p);
239 named.insert(if project.is_empty() {
240 ".".to_owned()
241 } else {
242 project.replace(':', "/")
243 });
244 }
245 (!named.is_empty()).then_some(named)
246 }
247 JvmBuild::Maven => {
248 let at = arguments
249 .iter()
250 .position(|a| a == "-pl" || a == "--projects")?;
251 let list = arguments.get(at + 1)?;
252 let named = list
253 .split(',')
254 .map(|m| {
255 m.trim()
256 .trim_start_matches("./")
257 .trim_end_matches('/')
258 .to_owned()
259 })
260 .collect::<BTreeSet<_>>();
261 (!named
263 .iter()
264 .any(|m| m.starts_with(':') || m.starts_with('!')))
265 .then_some(named)
266 }
267 JvmBuild::Plain => None,
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase", deny_unknown_fields)]
273pub struct DirectJvmRunRequest {
274 pub root: PathBuf,
275 pub command: Vec<String>,
276 pub run_id: String,
277 pub started_at: String,
278 #[serde(default)]
286 pub exact_attribution: bool,
287}
288
289#[derive(Debug, Clone, PartialEq)]
290pub struct DirectJvmRunResult {
291 pub run_id: String,
292 pub run_directory: PathBuf,
293 pub exit_code: i32,
294 pub tests: usize,
295 pub source_files: usize,
296 pub modules: usize,
297 pub build: JvmBuild,
298 pub recovered_runs: Vec<String>,
299 pub metadata: RunMetadata,
300}
301
302fn elapsed_ms(started: Instant) -> f64 {
303 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
304}
305
306fn write(path: &Path, contents: &str) -> Result<(), String> {
307 if let Some(parent) = path.parent() {
308 fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
309 }
310 fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
311}
312
313fn source_root(source_set: &str) -> String {
319 format!("src/{source_set}/java")
320}
321
322fn java_literal(value: &str) -> String {
325 let mut out = String::with_capacity(value.len() + 2);
326 out.push('"');
327 for character in value.chars() {
328 match character {
329 '"' => out.push_str("\\\""),
330 '\\' => out.push_str("\\\\"),
331 '\n' => out.push_str("\\n"),
332 '\r' => out.push_str("\\r"),
333 other => out.push(other),
334 }
335 }
336 out.push('"');
337 out
338}
339
340fn runtime_source(probe_count: usize) -> String {
350 let marker = "static final int PROBE_COUNT = 0; // supercov:probe-count";
351 debug_assert!(
352 RUNTIME_SOURCE.contains(marker),
353 "the runtime no longer declares the probe count Supercov substitutes"
354 );
355 RUNTIME_SOURCE.replace(
356 marker,
357 &format!("static final int PROBE_COUNT = {probe_count}; // supercov:probe-count"),
358 )
359}
360
361fn configuration(probe_count: usize, widths: &[u8], evidence: &Path) -> String {
363 let widths = widths
364 .iter()
365 .map(u8::to_string)
366 .collect::<Vec<_>>()
367 .join(", ");
368 format!(
369 "// 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",
370 java_literal(&evidence.to_string_lossy())
371 )
372}
373
374fn asks_for_parallel_execution(existing: Option<&str>) -> bool {
380 existing.unwrap_or("").lines().any(|line| {
381 let line = line.trim_start();
382 line.starts_with("junit.jupiter.execution.parallel.enabled")
383 && line
384 .split_once('=')
385 .is_some_and(|(_, value)| value.trim().eq_ignore_ascii_case("true"))
386 })
387}
388
389fn sequential_properties(existing: Option<&str>) -> String {
396 let mut out = String::new();
397 for line in existing.unwrap_or("").lines() {
398 if line
399 .trim_start()
400 .starts_with("junit.jupiter.execution.parallel.enabled")
401 {
402 continue;
403 }
404 out.push_str(line);
405 out.push('\n');
406 }
407 out.push_str(
408 "# Set by Supercov: probes are a store into one shared array, so two tests running\n\
409 # at once cannot both be credited with what they reached.\n\
410 junit.jupiter.execution.parallel.enabled=false\n",
411 );
412 out
413}
414
415const LAUNCHER_ARTIFACT: &str = "junit-platform-launcher";
424
425fn launcher_version(build_file: &str) -> Option<String> {
433 if build_file.contains("junit-bom") {
439 return None;
440 }
441 Some(jupiter_version(build_file).unwrap_or_else(|| DEFAULT_LAUNCHER_VERSION.to_owned()))
442}
443
444fn maven_launcher_version(directory: &Path, pom: &str) -> Option<String> {
446 let effective = maven_effective(directory, pom);
447 if effective.contains("junit-bom") {
448 return None;
449 }
450 if let Some(version) = jupiter_version(&effective) {
451 return Some(version);
452 }
453 let versionless = pom.contains("<artifactId>junit-jupiter") && jupiter_version(pom).is_none();
457 if pom.contains("<parent>") && versionless {
458 return None;
459 }
460 Some(DEFAULT_LAUNCHER_VERSION.to_owned())
461}
462
463fn jupiter_version(build_file: &str) -> Option<String> {
467 build_file
468 .match_indices("junit-jupiter")
469 .find_map(|(at, _)| {
470 let rest = &build_file[at..];
471 let gradle = rest
473 .split_once(':')
474 .filter(|(artifact, _)| !artifact.contains(char::is_whitespace))
475 .map(|(_, tail)| tail);
476 let maven = rest
478 .split("</dependency>")
479 .next()
480 .and_then(|dependency| dependency.split_once("<version>"))
481 .map(|(_, tail)| tail);
482 [gradle, maven]
483 .into_iter()
484 .flatten()
485 .find_map(platform_of_jupiter)
486 })
487}
488
489fn platform_of_jupiter(text: &str) -> Option<String> {
492 let version = text
493 .chars()
494 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
495 .collect::<String>();
496 let mut parts = version.splitn(3, '.');
497 let (major, minor, patch) = (parts.next()?, parts.next()?, parts.next()?);
498 if minor.is_empty() || patch.is_empty() || !minor.chars().all(|c| c.is_ascii_digit()) {
499 return None;
500 }
501 match major {
502 "5" => Some(format!("1.{minor}.{patch}")),
503 m if m.parse::<u32>().is_ok_and(|m| m >= 6) => Some(version),
504 _ => None,
505 }
506}
507
508fn maven_effective(directory: &Path, pom: &str) -> String {
517 let mut chain = vec![pom.to_owned()];
518 let mut here = directory.to_path_buf();
519 let mut current = pom.to_owned();
520 for _ in 0..10 {
521 let Some(parent) = xml_block(¤t, "parent") else {
522 break;
523 };
524 let field = |name: &str| xml_block(&parent, name).map(|v| v.trim().to_owned());
525 let (Some(artifact), Some(version)) = (field("artifactId"), field("version")) else {
526 break;
527 };
528 let group = field("groupId").unwrap_or_default();
529 let relative = field("relativePath").unwrap_or_else(|| "../pom.xml".into());
530 let mut on_disk = here.join(&relative);
531 if on_disk.is_dir() {
532 on_disk = on_disk.join("pom.xml");
533 }
534 let local = fs::read_to_string(&on_disk)
535 .ok()
536 .filter(|text| xml_block(text, "artifactId").is_some_and(|a| a.trim() == artifact));
537 let (text, next) = match local {
538 Some(text) => (text, on_disk.parent().map(Path::to_path_buf)),
539 None => match local_repository().map(|repo| {
540 repo.join(group.replace('.', "/"))
541 .join(&artifact)
542 .join(&version)
543 .join(format!("{artifact}-{version}.pom"))
544 }) {
545 Some(path) => match fs::read_to_string(&path) {
546 Ok(text) => (text, path.parent().map(Path::to_path_buf)),
547 Err(_) => break,
548 },
549 None => break,
550 },
551 };
552 chain.push(text.clone());
553 current = text;
554 match next {
555 Some(next) => here = next,
556 None => break,
557 }
558 }
559 let mut properties = BTreeMap::new();
560 for text in &chain {
561 if let Some(block) = xml_block(text, "properties") {
562 for (name, value) in xml_children(&block) {
563 properties.entry(name).or_insert(value);
564 }
565 }
566 for (name, value) in [("project.version", xml_block(text, "version"))] {
567 if let Some(value) = value {
568 properties
569 .entry(name.to_owned())
570 .or_insert(value.trim().to_owned());
571 }
572 }
573 }
574 let mut out = chain.join("\n");
575 for _ in 0..3 {
576 for (name, value) in &properties {
577 out = out.replace(&format!("${{{name}}}"), value);
578 }
579 }
580 out
581}
582
583fn xml_block(text: &str, name: &str) -> Option<String> {
585 let open = format!("<{name}>");
586 let start = text.find(&open)? + open.len();
587 let end = text[start..].find(&format!("</{name}>"))?;
588 Some(text[start..start + end].to_owned())
589}
590
591fn xml_children(block: &str) -> Vec<(String, String)> {
593 let mut out = Vec::new();
594 let mut rest = block;
595 while let Some(open) = rest.find('<') {
596 rest = &rest[open + 1..];
597 let Some(close) = rest.find('>') else { break };
598 let name = &rest[..close];
599 if name.starts_with(['/', '!', '?']) || name.contains(char::is_whitespace) {
600 rest = &rest[close + 1..];
601 continue;
602 }
603 let end_tag = format!("</{name}>");
604 let body = &rest[close + 1..];
605 let Some(end) = body.find(&end_tag) else {
606 break;
607 };
608 out.push((name.to_owned(), body[..end].trim().to_owned()));
609 rest = &body[end + end_tag.len()..];
610 }
611 out
612}
613
614fn local_repository() -> Option<PathBuf> {
617 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
618 let m2 = PathBuf::from(home).join(".m2");
619 let configured = fs::read_to_string(m2.join("settings.xml"))
620 .ok()
621 .and_then(|text| xml_block(&text, "localRepository"))
622 .map(|p| PathBuf::from(p.trim()));
623 Some(configured.unwrap_or_else(|| m2.join("repository")))
624}
625
626const DEFAULT_LAUNCHER_VERSION: &str = "1.10.2";
627
628fn engine_version_of_platform(platform: &str) -> String {
630 match platform.strip_prefix("1.") {
632 Some(rest) => format!("5.{rest}"),
633 None => platform.to_owned(),
634 }
635}
636
637fn maven_with_launcher(pom: &str, platform: Option<String>) -> Option<String> {
639 maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT], platform)
640}
641
642const VINTAGE_ARTIFACT: &str = "junit-vintage-engine";
650
651fn maven_with_vintage(pom: &str, platform: Option<String>) -> Option<String> {
652 maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT, VINTAGE_ARTIFACT], platform)
653}
654
655fn maven_with_test_artifacts(
656 pom: &str,
657 artifacts: &[&str],
658 platform: Option<String>,
659) -> Option<String> {
660 let missing = artifacts
661 .iter()
662 .filter(|artifact| !pom.contains(**artifact))
663 .collect::<Vec<_>>();
664 if missing.is_empty() {
665 return None;
666 }
667 let dependency = missing
668 .iter()
669 .map(|artifact| {
670 let (group, version) = if **artifact == VINTAGE_ARTIFACT {
674 (
675 "org.junit.vintage",
676 platform.as_deref().map(engine_version_of_platform),
677 )
678 } else {
679 ("org.junit.platform", platform.clone())
680 };
681 let version = version
682 .map(|version| format!("\n <version>{version}</version>"))
683 .unwrap_or_default();
684 format!(
685 " <dependency>\n <groupId>{group}</groupId>\n <artifactId>{artifact}</artifactId>{version}\n <scope>test</scope>\n </dependency>\n"
686 )
687 })
688 .collect::<String>();
689 match project_dependencies_end(pom) {
690 Some(at) => Some(format!("{}{dependency}{}", &pom[..at], &pom[at..])),
691 None => pom.rfind("</project>").map(|at| {
693 format!(
694 "{} <dependencies>\n{dependency} </dependencies>\n{}",
695 &pom[..at],
696 &pom[at..]
697 )
698 }),
699 }
700}
701
702fn project_dependencies_end(pom: &str) -> Option<usize> {
711 const NESTED: [&str; 4] = ["dependencyManagement", "profiles", "build", "reporting"];
712 let bytes = pom.as_bytes();
713 let mut depth = 0usize;
714 let mut at = 0usize;
715 while at < bytes.len() {
716 let Some(open) = pom[at..].find('<') else {
717 break;
718 };
719 let start = at + open;
720 let Some(close) = pom[start..].find('>') else {
721 break;
722 };
723 let tag = &pom[start + 1..start + close];
724 at = start + close + 1;
725 let name = tag.trim_start_matches('/').trim_end_matches('/').trim();
726 let name = name.split_whitespace().next().unwrap_or_default();
727 if NESTED.contains(&name) {
728 if tag.starts_with('/') {
729 depth = depth.saturating_sub(1);
730 } else if !tag.ends_with('/') {
731 depth += 1;
732 }
733 } else if name == "dependencies" && tag.starts_with('/') && depth == 0 {
734 return Some(start);
735 }
736 }
737 None
738}
739
740fn declares_launcher_for_compilation(build_file: &str) -> bool {
750 build_file.lines().any(|line| {
751 line.contains(LAUNCHER_ARTIFACT)
752 && ["testImplementation", "testCompileOnly", "testApi"]
753 .iter()
754 .any(|configuration| line.contains(configuration))
755 })
756}
757
758fn gradle_with_launcher(
773 build_file: &str,
774 kotlin: bool,
775 catalog: &str,
776 junit4: &[String],
777) -> Option<String> {
778 let launcher = !declares_launcher_for_compilation(build_file);
779 if !launcher && junit4.is_empty() {
780 return None;
781 }
782 let platform = launcher_version(&format!("{build_file}\n{}", catalog_versions(catalog)));
783 let coordinate = |group: &str, artifact: &str, version: Option<String>| match version {
784 Some(version) => format!("{group}:{artifact}:{version}"),
785 None => format!("{group}:{artifact}"),
786 };
787 let dependency = |indent: &str, configuration: &str, coordinate: String| {
790 if kotlin {
791 format!("{indent}\"{configuration}\"(\"{coordinate}\")\n")
792 } else {
793 format!("{indent}{configuration} '{coordinate}'\n")
794 }
795 };
796 let mut body = String::new();
802 if launcher {
803 body.push_str(" dependencies {\n");
804 body.push_str(&dependency(
805 " ",
806 "testImplementation",
807 coordinate("org.junit.platform", LAUNCHER_ARTIFACT, platform.clone()),
808 ));
809 body.push_str(" }\n");
810 }
811 if !junit4.is_empty() {
812 let quote = |text: &str| {
814 let text = if text == "." { "" } else { text };
815 format!("\"{}\"", text.replace('\\', "/"))
816 };
817 let projects = junit4
818 .iter()
819 .map(|p| quote(p))
820 .collect::<Vec<_>>()
821 .join(", ");
822 let relative =
825 "rootDir.toPath().relativize(projectDir.toPath()).toString().replace('\\\\', '/')";
826 let (list, test_tasks, still_junit4) = if kotlin {
827 (
828 format!("listOf({projects})"),
829 "tasks.withType<Test>().configureEach",
830 "options is JUnitOptions",
831 )
832 } else {
833 (
834 format!("[{projects}]"),
835 "tasks.withType(Test).configureEach",
836 "options instanceof JUnitOptions",
837 )
838 };
839 body.push_str(&format!(" if ({relative} in {list}) {{\n"));
840 body.push_str(" dependencies {\n");
841 body.push_str(&dependency(
843 " ",
844 "testRuntimeOnly",
845 coordinate(
846 "org.junit.vintage",
847 VINTAGE_ARTIFACT,
848 platform.as_deref().map(engine_version_of_platform),
849 ),
850 ));
851 body.push_str(" }\n");
852 body.push_str(&format!(
853 " {test_tasks} {{\n if ({still_junit4}) useJUnitPlatform()\n }}\n"
854 ));
855 body.push_str(" }\n");
856 }
857 let plugin = if kotlin { "\"java\"" } else { "'java'" };
858 Some(format!(
859 "{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. JUnit 4 tests run on the platform through Vintage.\nallprojects {{\n plugins.withId({plugin}) {{\n{body} }}\n}}\n"
860 ))
861}
862
863fn catalog_versions(catalog: &str) -> String {
867 let versions = catalog
868 .split("[versions]")
869 .nth(1)
870 .map(|rest| rest.split("\n[").next().unwrap_or(""))
871 .unwrap_or("")
872 .lines()
873 .filter_map(|line| {
874 let (key, value) = line.split_once('=')?;
875 Some((
876 key.trim().to_owned(),
877 value.trim().trim_matches('"').to_owned(),
878 ))
879 })
880 .collect::<BTreeMap<_, _>>();
881 let mut out = String::new();
882 for line in catalog.lines().filter(|l| l.contains("org.junit")) {
883 if line.contains("junit-bom") {
884 out.push_str("junit-bom\n");
885 }
886 if !line.contains("org.junit.jupiter") {
887 continue;
888 }
889 let quoted = |key: &str| {
890 line.split(key)
891 .nth(1)
892 .and_then(|r| r.split('"').nth(1))
893 .map(str::to_owned)
894 };
895 let version = quoted("version.ref")
896 .and_then(|key| versions.get(&key).cloned())
897 .or_else(|| quoted("version ="))
898 .or_else(|| quoted("version="));
899 if let Some(version) = version {
900 out.push_str(&format!("org.junit.jupiter:junit-jupiter:{version}\n"));
901 }
902 }
903 out
904}
905
906fn checks_license_headers(workspace: &Path) -> bool {
908 fs::read_to_string(workspace.join("pom.xml"))
909 .is_ok_and(|pom| maven_effective(workspace, &pom).contains("apache-rat"))
910}
911
912fn note_relaxations(existing: &str, updated: &str, relaxed: &mut Vec<&'static str>) {
917 let fail_on_warning = "<failOnWarning>false</failOnWarning>";
918 let warnings = updated.contains(fail_on_warning) != existing.contains(fail_on_warning)
919 || existing.contains("-Werror") && !updated.contains("-Werror");
920 let error_prone =
921 existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne");
922 for (changed, note) in [
923 (warnings, "stopped the build failing on warnings"),
924 (error_prone, "switched Error Prone off"),
925 ] {
926 if changed && !relaxed.contains(¬e) {
927 relaxed.push(note);
928 }
929 }
930}
931
932fn without_warnings_as_errors(build_file: &str) -> Option<String> {
945 let mut updated = build_file.to_owned();
946 for (from, to) in [
947 (
948 "<failOnWarning>true</failOnWarning>",
949 "<failOnWarning>false</failOnWarning>",
950 ),
951 (
952 "<failOnWarnings>true</failOnWarnings>",
953 "<failOnWarnings>false</failOnWarnings>",
954 ),
955 ("<arg>-Werror</arg>", ""),
956 ("<compilerArgument>-Werror</compilerArgument>", ""),
957 ("options.compilerArgs << '-Werror'", ""),
958 ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
959 ] {
960 updated = updated.replace(from, to);
961 }
962 updated = without_error_prone(&updated);
963 (updated != build_file).then_some(updated)
964}
965
966fn without_error_prone(build_file: &str) -> String {
978 let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
979 return build_file.to_owned();
980 };
981 let Some(end) = build_file[start..].find("</arg>") else {
982 return build_file.to_owned();
983 };
984 let mut updated = build_file.to_owned();
985 updated.replace_range(start..start + end + "</arg>".len(), "");
986 updated
987}
988
989fn command_with_fresh_results(
994 build: JvmBuild,
995 command: &[String],
996) -> (Vec<String>, Option<String>) {
997 let mut updated = command.to_vec();
998 match build {
999 JvmBuild::Gradle => {
1000 if updated
1001 .iter()
1002 .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
1003 {
1004 return (updated, None);
1005 }
1006 updated.push("--rerun-tasks".into());
1007 (
1008 updated,
1009 Some(
1010 "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
1011 .into(),
1012 ),
1013 )
1014 }
1015 JvmBuild::Maven | JvmBuild::Plain => (updated, None),
1018 }
1019}
1020
1021#[derive(Debug, Clone)]
1030struct JvmModule {
1031 directory: String,
1033 evidence: PathBuf,
1037 has_tests: bool,
1038}
1039
1040struct InstrumentedWorkspace {
1041 project: PreparedJvmProject,
1042 build: JvmBuild,
1043 modules: Vec<JvmModule>,
1044 declared_in: BTreeMap<String, String>,
1046 added_launcher: Option<&'static str>,
1048 serialised: BTreeSet<String>,
1052 concurrent: BTreeSet<String>,
1055 added_vintage: bool,
1058 relaxed: Vec<&'static str>,
1061 license_check: bool,
1064 unmeasurable: Vec<String>,
1067 modular: Vec<String>,
1069}
1070
1071fn module_of(relative: &str) -> String {
1079 match relative.find("src/") {
1080 Some(0) | None => ".".to_owned(),
1081 Some(at) => relative[..at].trim_end_matches('/').to_owned(),
1082 }
1083}
1084
1085fn class_of(test_name: &str) -> Option<&str> {
1088 test_name.split('#').next().filter(|name| !name.is_empty())
1089}
1090
1091fn declaring_file(declared_in: &BTreeMap<String, String>, class: &str) -> Option<String> {
1099 let top = class.split('$').next().unwrap_or(class);
1100 let path = top.replace('.', "/");
1101 let simple = top.rsplit('.').next().unwrap_or(top);
1102 let by_path = declared_in.values().find(|file| {
1103 file.rsplit_once('.').is_some_and(|(stem, _)| {
1104 stem.ends_with(&path) && stem[..stem.len() - path.len()].ends_with('/') || stem == path
1105 })
1106 });
1107 by_path.or_else(|| declared_in.get(simple)).cloned()
1108}
1109
1110fn instrument_workspace(
1111 workspace: &Path,
1112 evidence_directory: &Path,
1113 exact_attribution: bool,
1114 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
1115) -> Result<InstrumentedWorkspace, String> {
1116 let project = prepare_jvm_project(workspace, roots)?;
1117 let build = detect_build(workspace);
1118
1119 for (relative, instrumented) in &project.instrumented {
1120 write(&workspace.join(relative), instrumented)?;
1121 }
1122
1123 let probe_count = project
1124 .probes
1125 .keys()
1126 .max()
1127 .map_or(0, |highest| *highest as usize + 1);
1128
1129 let catalog = version_catalog(workspace);
1135 let frameworks_of = |directory: &str| -> Frameworks {
1136 let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
1137 let mut text = names
1138 .iter()
1139 .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
1140 .unwrap_or_default();
1141 for name in names {
1142 if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
1143 text.push('\n');
1144 text.push_str(&own);
1145 break;
1146 }
1147 }
1148 frameworks(&with_catalog(&text, &catalog))
1149 };
1150 let mut unmeasurable: Vec<String> = Vec::new();
1151 let mut modular: Vec<String> = Vec::new();
1152
1153 let mut modules: BTreeMap<String, bool> = BTreeMap::new();
1156 for (relative, _) in &project.files.sources {
1157 modules.entry(module_of(relative)).or_insert(false);
1158 }
1159 for (relative, _) in &project.files.tests {
1160 *modules.entry(module_of(relative)).or_default() = true;
1161 }
1162 for module in modules.keys().cloned().collect::<Vec<_>>() {
1169 if workspace.join(&module).join("src/test").is_dir() {
1170 modules.insert(module, true);
1171 }
1172 }
1173 if modules.is_empty() {
1174 modules.insert(".".to_owned(), true);
1175 }
1176
1177 let modules = modules
1178 .into_iter()
1179 .map(|(directory, has_tests)| JvmModule {
1180 evidence: evidence_directory.join(
1181 directory
1182 .chars()
1183 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1184 .collect::<String>(),
1185 ),
1186 directory,
1187 has_tests,
1188 })
1189 .collect::<Vec<_>>();
1190
1191 for module in &modules {
1192 let frameworks = frameworks_of(&module.directory);
1193 let at = |source_set: &str| {
1194 workspace
1195 .join(&module.directory)
1196 .join(source_root(source_set))
1197 .join(PACKAGE_DIRECTORY)
1198 };
1199 write(
1204 &at("main").join("Supercov.java"),
1205 &runtime_source(probe_count),
1206 )?;
1207 if !module.has_tests {
1208 continue;
1209 }
1210 if frameworks.junit4 && build == JvmBuild::Plain {
1211 unmeasurable.push(module.directory.clone());
1216 continue;
1217 }
1218 if workspace
1228 .join(&module.directory)
1229 .join(source_root("test"))
1230 .join("module-info.java")
1231 .exists()
1232 {
1233 modular.push(module.directory.clone());
1234 continue;
1235 }
1236 let test = at("test");
1239 write(
1240 &test.join("SupercovConfig.java"),
1241 &configuration(probe_count, &project.decision_widths, &module.evidence),
1242 )?;
1243 if frameworks.platform || frameworks.junit4 {
1244 write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
1245 }
1246 if frameworks.testng {
1247 write(
1248 &test.join("SupercovTestNGListener.java"),
1249 TESTNG_LISTENER_SOURCE,
1250 )?;
1251 }
1252 }
1253
1254 let mut relaxed: Vec<&'static str> = Vec::new();
1257 for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
1258 let path = workspace.join(name);
1259 let Ok(existing) = fs::read_to_string(&path) else {
1260 continue;
1261 };
1262 let Some(updated) = without_warnings_as_errors(&existing) else {
1263 continue;
1264 };
1265 note_relaxations(&existing, &updated, &mut relaxed);
1266 write(&path, &updated)?;
1267 }
1268 let license_check = build == JvmBuild::Maven && checks_license_headers(workspace);
1269
1270 let mut added_launcher = None;
1275 let mut serialised: BTreeSet<String> = BTreeSet::new();
1276 let mut concurrent: BTreeSet<String> = BTreeSet::new();
1277 let mut added_vintage = false;
1278 match build {
1279 JvmBuild::Maven => {
1283 for module in modules
1284 .iter()
1285 .filter(|module| {
1286 module.has_tests
1287 && !unmeasurable.contains(&module.directory)
1288 && !modular.contains(&module.directory)
1289 })
1290 .filter(|module| {
1294 let frameworks = frameworks_of(&module.directory);
1295 frameworks.platform || frameworks.junit4
1296 })
1297 {
1298 let pom = workspace.join(&module.directory).join("pom.xml");
1299 let Ok(existing) = fs::read_to_string(&pom) else {
1300 continue;
1301 };
1302 let platform =
1306 maven_launcher_version(&workspace.join(&module.directory), &existing);
1307 let updated = if frameworks_of(&module.directory).junit4 {
1308 added_vintage = true;
1309 maven_with_vintage(&existing, platform)
1310 } else {
1311 maven_with_launcher(&existing, platform)
1312 };
1313 if let Some(updated) = updated {
1314 write(&pom, &updated)?;
1315 added_launcher = Some("pom.xml");
1316 }
1317 }
1318 }
1319 JvmBuild::Gradle
1320 if !modules.iter().any(|module| {
1321 let frameworks = frameworks_of(&module.directory);
1322 module.has_tests && (frameworks.platform || frameworks.junit4)
1323 }) => {}
1324 JvmBuild::Gradle => {
1325 let junit4 = modules
1328 .iter()
1329 .filter(|module| {
1330 let frameworks = frameworks_of(&module.directory);
1331 module.has_tests
1332 && frameworks.junit4
1333 && !frameworks.testng
1334 && !modular.contains(&module.directory)
1335 })
1336 .map(|module| module.directory.clone())
1337 .collect::<Vec<_>>();
1338 for name in ["build.gradle.kts", "build.gradle"] {
1339 let path = workspace.join(name);
1340 let Ok(existing) = fs::read_to_string(&path) else {
1341 continue;
1342 };
1343 let catalog = fs::read_to_string(workspace.join("gradle/libs.versions.toml"))
1344 .unwrap_or_default();
1345 if let Some(updated) =
1346 gradle_with_launcher(&existing, name.ends_with(".kts"), &catalog, &junit4)
1347 {
1348 added_vintage |= !junit4.is_empty();
1349 write(&path, &updated)?;
1350 added_launcher = Some(if name.ends_with(".kts") {
1351 "build.gradle.kts"
1352 } else {
1353 "build.gradle"
1354 });
1355 }
1356 break;
1357 }
1358 }
1359 JvmBuild::Plain => {}
1362 }
1363
1364 for module in modules.iter().filter(|module| {
1365 module.has_tests
1366 && !unmeasurable.contains(&module.directory)
1367 && !modular.contains(&module.directory)
1368 }) {
1369 let frameworks = frameworks_of(&module.directory);
1370 let resources = workspace.join(&module.directory).join("src/test/resources");
1371 if frameworks.platform || frameworks.junit4 {
1372 write(
1373 &resources.join(SERVICES_FILE),
1374 &format!("{LISTENER_CLASS}\n"),
1375 )?;
1376 let properties = resources.join("junit-platform.properties");
1377 let existing = fs::read_to_string(&properties).ok();
1378 if !exact_attribution {
1379 if asks_for_parallel_execution(existing.as_deref()) {
1385 concurrent.insert(module.directory.clone());
1386 }
1387 continue;
1388 }
1389 if asks_for_parallel_execution(existing.as_deref()) {
1396 serialised.insert(module.directory.clone());
1397 }
1398 write(&properties, &sequential_properties(existing.as_deref()))?;
1399 }
1400 if frameworks.testng {
1401 write(
1402 &resources.join(TESTNG_SERVICES_FILE),
1403 &format!("{TESTNG_LISTENER_CLASS}\n"),
1404 )?;
1405 }
1406 }
1407
1408 let mut declared_in = BTreeMap::new();
1412 for (relative, _) in &project.files.tests {
1413 if let Some(stem) = relative
1414 .rsplit('/')
1415 .next()
1416 .and_then(|name| name.split('.').next())
1417 {
1418 declared_in.insert(stem.to_owned(), relative.clone());
1419 }
1420 }
1421
1422 Ok(InstrumentedWorkspace {
1423 project,
1424 build,
1425 modules,
1426 declared_in,
1427 added_launcher,
1428 serialised,
1429 concurrent,
1430 added_vintage,
1431 relaxed,
1432 license_check,
1433 unmeasurable,
1434 modular,
1435 })
1436}
1437
1438pub fn current_jvm_integrity(
1440 root: &Path,
1441 command: &[String],
1442 source_roots: Option<&[String]>,
1443) -> Result<crate::run_store::RunIntegrity, String> {
1444 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1445 let files = crate::jvm_project::discover_jvm_files(&root)?;
1446 let mut inputs = jvm_integrity_inputs(&files, command);
1447 crate::source_discovery::fold_roots_into_configuration(
1448 &mut inputs.execution_configuration,
1449 source_roots,
1450 );
1451 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_jvm())
1452 .map_err(|error| error.to_string())
1453}
1454
1455pub fn run_direct_jvm(
1456 request: &DirectJvmRunRequest,
1457 diagnostics: &mut dyn Write,
1458) -> Result<DirectJvmRunResult, String> {
1459 if request.command.is_empty() {
1460 return Err("test command must not be empty".into());
1461 }
1462 let total_started = Instant::now();
1463 let initialization_started = Instant::now();
1464 let root = canonicalize_simplified(&request.root)
1465 .map_err(|error| format!("{}: {error}", request.root.display()))?;
1466 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1467 .map_err(|error| error.to_string())?;
1468 let initialization_ms = elapsed_ms(initialization_started);
1469 let work_directory = root.join(".supercov/work").join(&request.run_id);
1470 let measured = std::cell::Cell::new(false);
1474 let result = (|| {
1475 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1476 .map_err(|error| error.to_string())?;
1477 if !recovered_runs.is_empty() {
1478 writeln!(
1479 diagnostics,
1480 "[supercov] recovered abandoned run(s): {}",
1481 recovered_runs.join(", ")
1482 )
1483 .map_err(|error| error.to_string())?;
1484 }
1485
1486 let adapter_started = Instant::now();
1487 let files = crate::jvm_project::discover_jvm_files(&root)?;
1488 let source_roots = crate::source_discovery::configured_source_roots(
1489 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1490 );
1491 let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1492 crate::source_discovery::fold_roots_into_configuration(
1493 &mut integrity_inputs.execution_configuration,
1494 source_roots.as_deref(),
1495 );
1496 let source_inputs =
1497 crate::source_capture::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1498 let integrity = create_explicit_run_integrity(
1499 &root,
1500 &integrity_inputs,
1501 &FrontendIntegrityInputs::embedded_jvm(),
1502 )
1503 .map_err(|error| error.to_string())?;
1504
1505 let workspace_started = Instant::now();
1506 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1507 let workspace =
1508 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1509 let evidence_directory = work_directory.join("jvm");
1510 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1511 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1512 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1513 &root, &workspace, &ambient,
1514 )
1515 .map_err(|error| error.to_string())?;
1516 let instrumented = instrument_workspace(
1517 &workspace,
1518 &evidence_directory,
1519 request.exact_attribution,
1520 roots.as_ref(),
1521 )?;
1522 let workspace_preparation_ms = elapsed_ms(workspace_started);
1523 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1524 writeln!(
1525 diagnostics,
1526 "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1527 match instrumented.build {
1528 JvmBuild::Maven => "a Maven project",
1529 JvmBuild::Gradle => "a Gradle project",
1530 JvmBuild::Plain => "Java/Kotlin sources",
1531 },
1532 instrumented.project.instrumented.len(),
1533 workspace.display()
1534 )
1535 .map_err(|error| error.to_string())?;
1536 if let Some(build_file) = instrumented.added_launcher {
1537 writeln!(
1538 diagnostics,
1539 "[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."
1540 )
1541 .map_err(|error| error.to_string())?;
1542 }
1543 if !instrumented.serialised.is_empty() {
1544 writeln!(
1545 diagnostics,
1546 "[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.",
1547 instrumented
1548 .serialised
1549 .iter()
1550 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1551 .collect::<Vec<_>>()
1552 .join(", ")
1553 )
1554 .map_err(|error| error.to_string())?;
1555 }
1556 if !instrumented.concurrent.is_empty() {
1557 writeln!(
1558 diagnostics,
1559 "[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.",
1560 instrumented
1561 .concurrent
1562 .iter()
1563 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1564 .collect::<Vec<_>>()
1565 .join(", ")
1566 )
1567 .map_err(|error| error.to_string())?;
1568 }
1569 if !instrumented.relaxed.is_empty() {
1570 writeln!(
1571 diagnostics,
1572 "[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.",
1573 instrumented.relaxed.join("; ")
1574 )
1575 .map_err(|error| error.to_string())?;
1576 }
1577 if instrumented.added_vintage {
1578 writeln!(
1579 diagnostics,
1580 "[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."
1581 )
1582 .map_err(|error| error.to_string())?;
1583 }
1584 if !instrumented.modular.is_empty() {
1585 writeln!(
1586 diagnostics,
1587 "[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.",
1588 instrumented.modular.len(),
1589 instrumented.modular.join(", ")
1590 )
1591 .map_err(|error| error.to_string())?;
1592 }
1593 if !instrumented.unmeasurable.is_empty() {
1594 writeln!(
1595 diagnostics,
1596 "[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.",
1597 instrumented.unmeasurable.len(),
1598 instrumented.unmeasurable.join(", ")
1599 )
1600 .map_err(|error| error.to_string())?;
1601 }
1602 for (file, reason) in &instrumented.project.unparseable {
1603 writeln!(
1604 diagnostics,
1605 "[supercov] could not parse {file}: {reason}; it carries no obligations"
1606 )
1607 .map_err(|error| error.to_string())?;
1608 }
1609
1610 let (mut command, note) = command_with_fresh_results(instrumented.build, &request.command);
1611 if instrumented.license_check && !command.iter().any(|a| a.contains("rat.skip")) {
1615 command.push("-Drat.skip=true".into());
1616 writeln!(
1617 diagnostics,
1618 "[supercov] added -Drat.skip=true: Apache RAT checks every file in the build for a license header, and the workspace copy holds Supercov's own runtime and listeners, which carry none. Your own build still runs the check."
1619 )
1620 .map_err(|error| error.to_string())?;
1621 }
1622 if let Some(note) = note {
1623 writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1624 }
1625 let test_started = Instant::now();
1626 let plan = ExecutionPlan {
1627 preparation: Vec::new(),
1628 test: ExecutionPhase {
1629 name: "test".into(),
1630 kind: PhaseKind::Test,
1631 command: CommandSpec {
1632 program: command[0].clone().into(),
1633 arguments: command[1..].iter().map(OsString::from).collect(),
1634 cwd: workspace.clone(),
1635 environment: None,
1636 captured_output: None,
1637 console: Some(crate::workspace::console_names(&root, &workspace)),
1638 },
1639 },
1640 };
1641 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1642 let before = crate::workspace::workspace_output_baseline(&workspace)
1645 .map_err(|error| error.to_string())?;
1646 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1647 .map_err(|error| error.to_string())?;
1648 let test_command_ms = elapsed_ms(test_started);
1649 let rewritten = instrumented
1654 .project
1655 .files
1656 .sources
1657 .iter()
1658 .chain(&instrumented.project.files.tests)
1659 .map(|(file, _)| PathBuf::from(file))
1660 .collect::<BTreeSet<_>>();
1661 let outputs = crate::workspace::sync_outputs_of(
1662 crate::workspace::CommandOutputs::Jvm,
1663 &root,
1664 &workspace,
1665 &before,
1666 &rewritten,
1667 )
1668 .map_err(|error| error.to_string())?;
1669 for line in crate::workspace::describe_command_outputs(&outputs)
1672 .into_iter()
1673 .filter(|line| !line.contains("stayed in the isolated workspace"))
1674 {
1675 writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
1676 }
1677 if let Some(signal) = execution.interrupted_signal {
1678 return Err(format!(
1679 "the test command was interrupted by {signal:?}; no run was published"
1680 ));
1681 }
1682 let exit_code = execution.exit_code;
1683
1684 let publication_started = Instant::now();
1685 let mut parts = Vec::new();
1687 let mut outcomes = Vec::new();
1688 let mut silent = Vec::new();
1689 let named = named_modules(instrumented.build, &request.command);
1690 for module in instrumented
1691 .modules
1692 .iter()
1693 .filter(|module| module.has_tests)
1694 {
1695 let mut written = fs::read_dir(&module.evidence)
1697 .map(|entries| {
1698 entries
1699 .flatten()
1700 .map(|entry| entry.path())
1701 .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1702 .collect::<Vec<_>>()
1703 })
1704 .unwrap_or_default();
1705 written.sort();
1706 if written.is_empty() {
1707 if named
1710 .as_ref()
1711 .is_none_or(|named| named.contains(&module.directory))
1712 {
1713 silent.push(module.directory.clone());
1714 }
1715 continue;
1716 }
1717 let mut forked = Vec::new();
1718 for path in &written {
1719 let bytes =
1720 fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1721 forked.push(
1722 read_evidence(&bytes)
1723 .map_err(|error| format!("{}: {error}", path.display()))?,
1724 );
1725 }
1726 let evidence = merge_evidence(forked);
1727 for test in &evidence.tests {
1728 outcomes.push(OwnedTestOutcome {
1729 name: test.name.clone(),
1730 runner: test.runner.clone(),
1731 package: module.directory.clone(),
1735 file: class_of(&test.name)
1736 .and_then(|class| declaring_file(&instrumented.declared_in, class)),
1737 status: test.status.clone(),
1738 attributed: !test.unattributed,
1744 });
1745 }
1746 parts.push(evidence);
1747 }
1748 if !silent.is_empty() {
1749 writeln!(
1750 diagnostics,
1751 "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1752 silent.len(),
1753 silent.join(", ")
1754 )
1755 .map_err(|error| error.to_string())?;
1756 }
1757 if outcomes.is_empty() {
1758 return Err(format!(
1759 "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."
1760 ));
1761 }
1762 let evidence = merge_evidence(parts);
1763 measured.set(true);
1764 let run = build_frontend_run(OwnedRunInputs {
1765 declaration: jvm_declaration(),
1766 environment: "jvm",
1767 manifest: &instrumented.project.manifest,
1768 probes: &instrumented.project.probes,
1769 evidence: &evidence,
1770 outcomes: &outcomes,
1771 run_id: &request.run_id,
1772 generated_at: &request.started_at,
1773 test_exit_code: exit_code,
1774 coverage_model: jvm_coverage_model(),
1775 })
1776 .map_err(|error| error.to_string())?;
1777 validate_frontend_report_request(&run.declaration, &run.request)
1778 .map_err(|error| error.to_string())?;
1779 let archive_path = work_directory.join("evidence.raw.gz");
1780 let raw = write_archive(
1781 crate::source_capture::append(
1782 run.archive_entries().map_err(|error| error.to_string())?,
1783 &source_inputs,
1784 )?,
1785 &archive_path,
1786 )
1787 .map_err(|error| error.to_string())?;
1788 let evidence_publication_ms = elapsed_ms(publication_started);
1789
1790 let timings = RunTimings {
1791 initialization_ms,
1792 workspace_preparation_ms,
1793 adapter_setup_ms,
1794 instrumented_build_ms: 0.0,
1795 test_command_ms,
1796 evidence_publication_ms,
1797 };
1798 let metadata = RunMetadata {
1799 id: request.run_id.clone(),
1800 started_at: request.started_at.clone(),
1801 duration_ms: elapsed_ms(total_started),
1802 command: request.command.clone(),
1803 test_exit_code: Some(exit_code),
1804 integrity,
1805 raw_evidence: RawEvidenceMetadata {
1806 schema_version: raw.schema_version,
1807 format: raw.format.into(),
1808 file: raw.file.into(),
1809 files: raw.files,
1810 uncompressed_bytes: raw.uncompressed_bytes,
1811 compressed_bytes: raw.compressed_bytes,
1812 },
1813 isolated_build: Some(true),
1814 instrumented_build_cache: None,
1815 timings: Some(timings),
1816 merged: None,
1817 parents: None,
1818 source_roots: source_roots.clone(),
1819 copy: Some(lock.copy()),
1820 merged_across: None,
1821 recorded_by: None,
1822 companions: None,
1823 };
1824 let run_directory =
1825 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1826 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1827 Ok(DirectJvmRunResult {
1828 run_id: request.run_id.clone(),
1829 run_directory,
1830 exit_code,
1831 tests: outcomes
1832 .iter()
1833 .map(|outcome| outcome.name.as_str())
1834 .collect::<BTreeSet<_>>()
1835 .len(),
1836 source_files: instrumented.project.instrumented.len(),
1837 modules: instrumented.modules.len(),
1838 build: instrumented.build,
1839 recovered_runs,
1840 metadata,
1841 })
1842 })();
1843 let result = result.map_err(|error| {
1844 if !measured.get() {
1845 return error;
1846 }
1847 note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1848 });
1849 if result.is_err() {
1850 let _ = remove_stored_tree_deferred(&root, &work_directory);
1851 }
1852 let release = lock.release().map_err(|error| error.to_string());
1853 match (result, release) {
1854 (Ok(result), Ok(())) => Ok(result),
1855 (Err(error), _) => Err(error),
1856 (Ok(_), Err(error)) => Err(error),
1857 }
1858}
1859
1860fn server_runtime_source(probe_count: usize, widths: &[u8]) -> String {
1864 let marker = "static final int[] SERVER_WIDTHS = new int[0]; // supercov:server-widths";
1865 debug_assert!(
1866 RUNTIME_SOURCE.contains(marker),
1867 "the runtime no longer declares the server widths Supercov substitutes"
1868 );
1869 let widths = widths
1870 .iter()
1871 .map(u8::to_string)
1872 .collect::<Vec<_>>()
1873 .join(", ");
1874 runtime_source(probe_count).replace(
1875 marker,
1876 &format!(
1877 "static final int[] SERVER_WIDTHS = new int[] {{{widths}}}; // supercov:server-widths"
1878 ),
1879 )
1880}
1881
1882pub struct JvmServer {
1890 project: PreparedJvmProject,
1891 integrity: crate::run_store::RunIntegrity,
1892 source_inputs: crate::source_manifest::Inputs,
1893 source_roots: Option<Vec<String>>,
1894 directory: PathBuf,
1895}
1896
1897impl JvmServer {
1898 pub fn prepare(
1901 root: &Path,
1902 workspace: &Path,
1903 run_id: &str,
1904 command: &[String],
1905 ) -> Result<Option<Self>, String> {
1906 let files = crate::jvm_project::discover_jvm_files(root)?;
1907 if files.sources.is_empty() {
1908 return Ok(None);
1909 }
1910 let ambient = std::env::vars().collect::<BTreeMap<_, _>>();
1911 let Ok(roots) = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1913 root, workspace, &ambient,
1914 ) else {
1915 return Ok(None);
1916 };
1917 let source_roots = crate::source_discovery::configured_source_roots(&ambient);
1918 let mut integrity_inputs = jvm_integrity_inputs(&files, command);
1919 crate::source_discovery::fold_roots_into_configuration(
1920 &mut integrity_inputs.execution_configuration,
1921 source_roots.as_deref(),
1922 );
1923 let source_inputs =
1924 crate::source_capture::capture(root, "jvm", integrity_inputs.assertion_paths())?;
1925 let integrity = create_explicit_run_integrity(
1926 root,
1927 &integrity_inputs,
1928 &FrontendIntegrityInputs::embedded_jvm(),
1929 )
1930 .map_err(|error| error.to_string())?;
1931 let project = prepare_jvm_project(workspace, roots.as_ref())?;
1932 if project.instrumented.is_empty() {
1933 return Ok(None);
1934 }
1935 for (relative, instrumented) in &project.instrumented {
1936 write(&workspace.join(relative), instrumented)?;
1937 }
1938 let probe_count = project
1939 .probes
1940 .keys()
1941 .max()
1942 .map_or(0, |highest| *highest as usize + 1);
1943 let runtime = server_runtime_source(probe_count, &project.decision_widths);
1944 let modules = project
1945 .files
1946 .sources
1947 .iter()
1948 .map(|(relative, _)| module_of(relative))
1949 .collect::<BTreeSet<_>>();
1950 for module in &modules {
1951 write(
1952 &workspace
1953 .join(module)
1954 .join(source_root("main"))
1955 .join(PACKAGE_DIRECTORY)
1956 .join("Supercov.java"),
1957 &runtime,
1958 )?;
1959 }
1960 let directory = root.join(".supercov/work").join(run_id).join("jvm");
1961 fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
1962 Ok(Some(Self {
1963 project,
1964 integrity,
1965 source_inputs,
1966 source_roots,
1967 directory,
1968 }))
1969 }
1970
1971 pub fn source_fingerprint(&self) -> String {
1973 self.integrity.fingerprint.source.clone()
1974 }
1975
1976 pub fn source_files(&self) -> usize {
1977 self.project.instrumented.len()
1978 }
1979
1980 pub fn environment(&self) -> BTreeMap<String, String> {
1983 BTreeMap::from([(
1984 "SUPERCOV_JVM_SERVER_EVIDENCE_DIR".into(),
1985 self.directory.join("evidence").display().to_string(),
1986 )])
1987 }
1988
1989 pub fn publish(
1990 self,
1991 root: &Path,
1992 _evidence_run_id: &str,
1993 metadata: RunMetadata,
1994 served: &[crate::served_tests::ServedTest],
1995 diagnostics: &mut dyn Write,
1996 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
1997 crate::owned_server::publish(
1998 root,
1999 crate::owned_server::OwnedServer {
2000 language: "Java/Kotlin",
2001 declaration: jvm_declaration(),
2002 environment: "jvm",
2003 coverage_model: jvm_coverage_model(),
2004 manifest: &self.project.manifest,
2005 probes: &self.project.probes,
2006 widths: &self.project.decision_widths,
2007 integrity: &self.integrity,
2008 source_inputs: &self.source_inputs,
2009 source_roots: self.source_roots.clone(),
2010 directory: &self.directory,
2011 source_files: self.project.instrumented.len(),
2012 },
2013 metadata,
2014 served,
2015 diagnostics,
2016 )
2017 }
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022 use super::*;
2023
2024 #[test]
2025 fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
2026 assert!(asks_for_parallel_execution(Some(
2030 "junit.jupiter.execution.parallel.enabled=true\n"
2031 )));
2032 assert!(asks_for_parallel_execution(Some(
2033 " junit.jupiter.execution.parallel.enabled = True \n"
2034 )));
2035 assert!(!asks_for_parallel_execution(Some(
2036 "junit.jupiter.execution.parallel.enabled=false\n"
2037 )));
2038 assert!(!asks_for_parallel_execution(Some(
2041 "junit.jupiter.execution.parallel.mode.default=concurrent\n"
2042 )));
2043 assert!(!asks_for_parallel_execution(None));
2044 assert!(!asks_for_parallel_execution(Some("")));
2045 }
2046
2047 #[test]
2048 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
2049 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
2053 junit.jupiter.execution.parallel.enabled=true\n\
2054 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
2055 let updated = sequential_properties(Some(existing));
2056 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
2057 assert!(
2058 !updated.contains("parallel.enabled=true"),
2059 "the project's own setting must not survive:\n{updated}"
2060 );
2061 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
2063 assert!(updated.contains("displayname.generator.default"));
2064
2065 assert!(
2067 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
2068 );
2069 }
2070
2071 #[test]
2072 fn gradle_is_told_to_run_the_tests_again() {
2073 let (command, note) = command_with_fresh_results(
2076 JvmBuild::Gradle,
2077 &["./gradlew".to_owned(), "test".to_owned()],
2078 );
2079 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
2080 assert!(note.is_some());
2081
2082 let (command, note) = command_with_fresh_results(
2084 JvmBuild::Gradle,
2085 &[
2086 "./gradlew".to_owned(),
2087 "test".to_owned(),
2088 "--rerun".to_owned(),
2089 ],
2090 );
2091 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
2092 assert!(note.is_none());
2093
2094 let (command, note) =
2096 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
2097 assert_eq!(command, ["mvn", "test"]);
2098 assert!(note.is_none());
2099 }
2100
2101 #[test]
2102 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
2103 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
2106 assert!(
2107 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
2108 "{configuration}"
2109 );
2110 assert!(configuration.contains("PROBES = 7"));
2111 assert!(configuration.contains("new int[] {2, 3}"));
2112 }
2113
2114 #[test]
2115 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
2116 assert_eq!(
2119 class_of("CalculatorTest#zeroIsNamed()"),
2120 Some("CalculatorTest")
2121 );
2122 assert_eq!(
2123 class_of("CalculatorSpec#a sum adds its parts"),
2124 Some("CalculatorSpec")
2125 );
2126 assert_eq!(class_of("Standalone"), Some("Standalone"));
2127 assert_eq!(class_of(""), None);
2128 }
2129
2130 #[test]
2131 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
2132 assert_eq!(
2137 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
2138 .as_deref(),
2139 Some("1.10.2")
2140 );
2141 assert_eq!(
2142 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
2143 .as_deref(),
2144 Some("1.13.1")
2145 );
2146 assert_eq!(
2149 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
2150 None
2151 );
2152 assert_eq!(
2155 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
2156 Some(DEFAULT_LAUNCHER_VERSION)
2157 );
2158 assert_eq!(
2164 launcher_version("<artifactId>parent</artifactId>").as_deref(),
2165 Some(DEFAULT_LAUNCHER_VERSION)
2166 );
2167 assert_eq!(
2168 launcher_version("").as_deref(),
2169 Some(DEFAULT_LAUNCHER_VERSION)
2170 );
2171 }
2172
2173 #[test]
2174 fn junit_is_versioned_through_parents_catalogs_and_junit_6() {
2175 assert_eq!(
2177 launcher_version("<artifactId>junit-jupiter</artifactId><version>6.0.1</version>")
2178 .as_deref(),
2179 Some("6.0.1")
2180 );
2181 assert_eq!(engine_version_of_platform("6.0.1"), "6.0.1");
2182 assert_eq!(
2185 launcher_version("<artifactId>junit-jupiter</artifactId><version>${junit}</version><plugin><version>5.2.1</version></plugin>")
2186 .as_deref(),
2187 Some(DEFAULT_LAUNCHER_VERSION)
2188 );
2189 let catalog = "[versions]\njunit = \"5.11.3\"\n\n[libraries]\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\n";
2191 let updated = gradle_with_launcher("plugins { id 'java' }\n", false, catalog, &[]).unwrap();
2192 assert!(
2193 updated.contains("junit-platform-launcher:1.11.3"),
2194 "{updated}"
2195 );
2196
2197 let root = std::env::temp_dir().join(format!(
2199 "supercov-junit-parents-{}-{}",
2200 std::process::id(),
2201 std::time::SystemTime::now()
2202 .duration_since(std::time::UNIX_EPOCH)
2203 .unwrap()
2204 .as_nanos()
2205 ));
2206 let module = root.join("module");
2207 fs::create_dir_all(&module).unwrap();
2208 let child = "<project><parent><groupId>g</groupId><artifactId>p</artifactId><version>1</version></parent><dependencies><dependency><artifactId>junit-jupiter-api</artifactId></dependency></dependencies></project>";
2209 fs::write(
2210 root.join("pom.xml"),
2211 "<project><artifactId>p</artifactId><dependencyManagement><dependencies><dependency><artifactId>junit-bom</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2212 )
2213 .unwrap();
2214 assert_eq!(maven_launcher_version(&module, child), None);
2215 fs::write(
2216 root.join("pom.xml"),
2217 "<project><artifactId>p</artifactId><properties><junit.version>5.14.4</junit.version></properties><dependencyManagement><dependencies><dependency><artifactId>junit-jupiter-api</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2218 )
2219 .unwrap();
2220 assert_eq!(
2221 maven_launcher_version(&module, child).as_deref(),
2222 Some("1.14.4")
2223 );
2224 fs::remove_file(root.join("pom.xml")).unwrap();
2226 let unknown = child.replace(
2227 "<artifactId>p</artifactId>",
2228 "<artifactId>not-downloaded</artifactId>",
2229 );
2230 assert_eq!(maven_launcher_version(&module, &unknown), None);
2231 fs::remove_dir_all(&root).ok();
2232 }
2233
2234 #[test]
2235 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
2236 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";
2237 let updated =
2238 maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2239 assert!(updated.contains("junit-platform-launcher"), "{updated}");
2240 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
2241 assert!(
2243 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
2244 "{updated}"
2245 );
2246 assert_eq!(
2248 maven_with_launcher(&updated, launcher_version(&updated)),
2249 None
2250 );
2251
2252 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
2254 let updated =
2255 maven_with_launcher(bare, launcher_version(bare)).expect("a block is created");
2256 assert!(updated.contains("<dependencies>"), "{updated}");
2257 assert!(
2258 updated.find("</dependencies>") < updated.find("</project>"),
2259 "{updated}"
2260 );
2261 }
2262
2263 #[test]
2264 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
2265 let groovy =
2266 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
2267 let updated =
2268 gradle_with_launcher(groovy, false, "", &[]).expect("the launcher is missing");
2269 assert!(
2270 updated
2271 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
2272 "{updated}"
2273 );
2274 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
2276 assert_eq!(gradle_with_launcher(&updated, false, "", &[]), None);
2277
2278 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
2284 let updated = gradle_with_launcher(runtime_only, false, "", &[])
2285 .expect("a runtime-only declaration does not reach the compiler");
2286 assert!(
2287 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
2288 "{updated}"
2289 );
2290 assert!(
2291 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
2292 "the project's own declaration stays:\n{updated}"
2293 );
2294
2295 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
2296 let updated = gradle_with_launcher(kotlin, true, "", &[]).expect("the launcher is missing");
2297 assert!(
2298 updated.contains(
2299 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
2300 ),
2301 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
2302 );
2303 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
2304 }
2305
2306 #[test]
2309 fn a_test_class_named_in_full_finds_its_file() {
2310 let declared_in = BTreeMap::from([
2311 (
2312 "AnnotationsTest".to_owned(),
2313 "src/test/java/com/fasterxml/classmate/AnnotationsTest.java".to_owned(),
2314 ),
2315 (
2316 "CalculatorSpec".to_owned(),
2317 "src/test/kotlin/app/CalculatorSpec.kt".to_owned(),
2318 ),
2319 ]);
2320 assert_eq!(
2321 declaring_file(&declared_in, "com.fasterxml.classmate.AnnotationsTest").as_deref(),
2322 Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2323 );
2324 assert_eq!(
2325 declaring_file(
2326 &declared_in,
2327 "com.fasterxml.classmate.AnnotationsTest$Nested"
2328 )
2329 .as_deref(),
2330 Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2331 );
2332 assert_eq!(
2333 declaring_file(&declared_in, "app.CalculatorSpec").as_deref(),
2334 Some("src/test/kotlin/app/CalculatorSpec.kt")
2335 );
2336 assert_eq!(
2338 declaring_file(&declared_in, "CalculatorSpec").as_deref(),
2339 Some("src/test/kotlin/app/CalculatorSpec.kt")
2340 );
2341 assert_eq!(declaring_file(&declared_in, "org.other.Missing"), None);
2342 }
2343
2344 #[test]
2345 fn a_license_header_check_is_found_through_the_parent_pom() {
2346 let root = std::env::temp_dir().join(format!(
2347 "supercov-rat-{}-{}",
2348 std::process::id(),
2349 std::time::SystemTime::now()
2350 .duration_since(std::time::UNIX_EPOCH)
2351 .unwrap()
2352 .as_nanos()
2353 ));
2354 fs::create_dir_all(root.join("parent")).unwrap();
2355 fs::write(
2356 root.join("parent/pom.xml"),
2357 "<project><artifactId>parent</artifactId><build><plugins><plugin><groupId>org.apache.rat</groupId><artifactId>apache-rat-plugin</artifactId></plugin></plugins></build></project>",
2358 )
2359 .unwrap();
2360 fs::write(
2361 root.join("pom.xml"),
2362 "<project><parent><groupId>g</groupId><artifactId>parent</artifactId><version>1</version><relativePath>parent/pom.xml</relativePath></parent></project>",
2363 )
2364 .unwrap();
2365 assert!(checks_license_headers(&root));
2366 fs::write(
2367 root.join("pom.xml"),
2368 "<project><artifactId>plain</artifactId></project>",
2369 )
2370 .unwrap();
2371 assert!(!checks_license_headers(&root));
2372 fs::remove_dir_all(&root).ok();
2373 }
2374
2375 #[test]
2376 fn only_the_modules_a_command_names_are_expected_to_write_evidence() {
2377 let words = |c: &str| c.split(' ').map(str::to_owned).collect::<Vec<_>>();
2378 assert_eq!(
2379 named_modules(
2380 JvmBuild::Gradle,
2381 &words("./gradlew :moshi:test --tests *Reader*")
2382 ),
2383 Some(BTreeSet::from(["moshi".to_owned()]))
2384 );
2385 assert_eq!(
2386 named_modules(
2387 JvmBuild::Gradle,
2388 &words("gradle :moshi:records-tests:test :test")
2389 ),
2390 Some(BTreeSet::from([
2391 "moshi/records-tests".to_owned(),
2392 ".".to_owned()
2393 ]))
2394 );
2395 assert_eq!(named_modules(JvmBuild::Gradle, &words("gradle test")), None);
2396 assert_eq!(
2397 named_modules(JvmBuild::Gradle, &words("gradle test -x :slow:test")),
2398 None
2399 );
2400 assert_eq!(
2401 named_modules(JvmBuild::Maven, &words("mvn -pl core,api/ test")),
2402 Some(BTreeSet::from(["core".to_owned(), "api".to_owned()]))
2403 );
2404 assert_eq!(
2405 named_modules(JvmBuild::Maven, &words("mvn -pl :core test")),
2406 None
2407 );
2408 assert_eq!(named_modules(JvmBuild::Maven, &words("mvn test")), None);
2409 assert_eq!(
2410 named_modules(JvmBuild::Maven, &words("mvn --projects ./core test")),
2411 Some(BTreeSet::from(["core".to_owned()]))
2412 );
2413 assert_eq!(
2415 named_modules(JvmBuild::Maven, &words("mvn -pl core,!slow test")),
2416 None
2417 );
2418 }
2419
2420 #[test]
2421 fn each_relaxation_is_said_once_however_many_build_files_needed_it() {
2422 let mut relaxed = Vec::new();
2423 for _ in 0..2 {
2425 note_relaxations("-Werror -Xplugin:ErrorProne", "-Xlint:none", &mut relaxed);
2426 }
2427 assert_eq!(
2428 relaxed,
2429 [
2430 "stopped the build failing on warnings",
2431 "switched Error Prone off"
2432 ]
2433 );
2434 let mut maven = Vec::new();
2435 note_relaxations(
2436 "<failOnWarning>true</failOnWarning>",
2437 "<failOnWarning>false</failOnWarning>",
2438 &mut maven,
2439 );
2440 assert_eq!(maven, ["stopped the build failing on warnings"]);
2441 let mut unchanged = Vec::new();
2442 note_relaxations("-Xlint:all", "-Xlint:all", &mut unchanged);
2443 assert!(unchanged.is_empty());
2444 }
2445
2446 #[test]
2447 fn the_platform_version_follows_jupiter_s() {
2448 assert_eq!(platform_of_jupiter("5.13.1").as_deref(), Some("1.13.1"));
2449 assert_eq!(
2450 platform_of_jupiter("5.10.0-M1</version>").as_deref(),
2451 Some("1.10.0-M1")
2452 );
2453 assert_eq!(platform_of_jupiter("6.0.1").as_deref(), Some("6.0.1"));
2454 for unknown in [
2455 "${junit.version}",
2456 "5.10",
2457 "5..1",
2458 "5.x.1",
2459 "4.13.2",
2460 "five.1.0",
2461 ] {
2462 assert_eq!(platform_of_jupiter(unknown), None, "{unknown}");
2463 }
2464 }
2465
2466 #[test]
2467 fn gradle_moves_junit_4_projects_onto_the_platform_through_vintage() {
2468 let kotlin = "dependencies {\n testImplementation(\"junit:junit:4.13.2\")\n}\n";
2469 let updated = gradle_with_launcher(kotlin, true, "", &[".".into(), "moshi".into()])
2470 .expect("a JUnit 4 project needs Vintage");
2471 assert!(
2472 updated
2473 .contains("\"testRuntimeOnly\"(\"org.junit.vintage:junit-vintage-engine:5.10.2\")"),
2474 "{updated}"
2475 );
2476 assert!(updated.contains("in listOf(\"\", \"moshi\")"), "{updated}");
2477 assert!(
2478 updated.contains("if (options is JUnitOptions) useJUnitPlatform()"),
2479 "only a task still on Gradle's JUnit 4 framework is switched:\n{updated}"
2480 );
2481
2482 let groovy = "dependencies {\n testImplementation 'junit:junit:4.13.2'\n testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'\n}\n";
2484 let updated = gradle_with_launcher(groovy, false, "", &["core".into()])
2485 .expect("a JUnit 4 project needs Vintage");
2486 assert!(
2487 updated.contains("testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2'"),
2488 "{updated}"
2489 );
2490 assert!(updated.contains("in [\"core\"]"), "{updated}");
2491 assert!(
2492 updated.contains("tasks.withType(Test).configureEach"),
2493 "{updated}"
2494 );
2495 assert!(
2496 !updated.contains("\n testImplementation 'org.junit.platform"),
2497 "the launcher is already there:\n{updated}"
2498 );
2499 }
2500
2501 #[test]
2502 fn only_the_listeners_a_project_can_compile_are_written() {
2503 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
2507 assert!(junit.platform && !junit.testng);
2508
2509 let testng = frameworks("<artifactId>testng</artifactId>");
2510 assert!(testng.testng && !testng.platform);
2511
2512 let both = frameworks("testng ... junit-jupiter");
2515 assert!(both.platform && both.testng);
2516
2517 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
2520 assert!(frameworks("org.spockframework:spock-core").platform);
2521
2522 let unknown = frameworks("<artifactId>demo</artifactId>");
2525 assert!(unknown.platform && !unknown.testng);
2526 }
2527
2528 #[test]
2529 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
2530 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
2536 assert!(four.junit4 && !four.platform && !four.testng);
2537
2538 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
2539 assert!(five.platform && !five.junit4);
2540
2541 let both = frameworks(
2544 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
2545 );
2546 assert!(both.platform && !both.junit4);
2547
2548 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
2550 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
2551 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
2552 }
2553
2554 #[test]
2555 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
2556 let root = std::env::temp_dir().join(format!(
2563 "supercov-jpms-{}-{}",
2564 std::process::id(),
2565 std::time::SystemTime::now()
2566 .duration_since(std::time::UNIX_EPOCH)
2567 .unwrap()
2568 .as_nanos()
2569 ));
2570 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();
2571 for module in ["lib", "boundaries"] {
2572 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
2573 write(
2574 &root.join(module).join("src/main/java/app/Api.java"),
2575 "package app;\npublic class Api { public int one() { return 1; } }\n",
2576 )
2577 .unwrap();
2578 write(
2579 &root.join(module).join("src/test/java/app/ApiTest.java"),
2580 "package app;\nclass ApiTest { void t() {} }\n",
2581 )
2582 .unwrap();
2583 }
2584 write(
2585 &root.join("boundaries/src/test/java/module-info.java"),
2586 "module app.boundaries {\n requires app.lib;\n}\n",
2587 )
2588 .unwrap();
2589
2590 let evidence = root.join("evidence");
2591 let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
2592 assert_eq!(instrumented.modular, ["boundaries"]);
2593
2594 assert!(
2596 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
2597 .exists()
2598 );
2599 for name in ["SupercovListener.java", "SupercovConfig.java"] {
2600 assert!(
2601 !root
2602 .join("boundaries/src/test/java/com/supercorp/supercov")
2603 .join(name)
2604 .exists(),
2605 "{name} must not be written into a named module"
2606 );
2607 }
2608 assert!(
2609 !root
2610 .join("boundaries/src/test/resources")
2611 .join(SERVICES_FILE)
2612 .exists(),
2613 "and nothing registers a listener that is not there"
2614 );
2615 assert!(
2619 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
2620 .exists()
2621 );
2622
2623 fs::remove_dir_all(root).ok();
2624 }
2625
2626 #[test]
2627 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
2628 let root = std::env::temp_dir().join(format!(
2633 "supercov-catalog-{}-{}",
2634 std::process::id(),
2635 std::time::SystemTime::now()
2636 .duration_since(std::time::UNIX_EPOCH)
2637 .unwrap()
2638 .as_nanos()
2639 ));
2640 write(
2641 &root.join("gradle/libs.versions.toml"),
2642 "[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",
2643 )
2644 .unwrap();
2645 let catalog = version_catalog(&root);
2646
2647 let junit4 = frameworks(&with_catalog(
2648 "dependencies { testImplementation(libs.junit) }",
2649 &catalog,
2650 ));
2651 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
2652
2653 let reflect = frameworks(&with_catalog(
2655 "dependencies { testImplementation(libs.kotlin.reflect) }",
2656 &catalog,
2657 ));
2658 assert!(!reflect.junit4, "{reflect:?}");
2659
2660 let jupiter = frameworks(&with_catalog(
2661 "dependencies { testImplementation(libs.jupiter) }",
2662 &catalog,
2663 ));
2664 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
2665
2666 let bundle = frameworks(&with_catalog(
2668 "dependencies { testImplementation(libs.bundles.unit) }",
2669 &catalog,
2670 ));
2671 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
2672
2673 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
2675 assert_eq!(with_catalog(plain, &catalog), plain);
2676
2677 fs::remove_dir_all(root).ok();
2678 }
2679
2680 #[test]
2681 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
2682 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";
2688 let updated =
2689 maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2690 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
2691 let managed_end = updated
2692 .find("</dependencyManagement>")
2693 .expect("managed block");
2694 let build_start = updated.find("<build>").expect("build block");
2695 assert!(
2696 at > managed_end,
2697 "not among the managed versions:\n{updated}"
2698 );
2699 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
2700 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
2703 }
2704
2705 #[test]
2706 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
2707 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";
2713 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
2714 assert!(
2715 updated.contains("<failOnWarning>false</failOnWarning>"),
2716 "{updated}"
2717 );
2718 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
2719 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
2722
2723 assert_eq!(without_warnings_as_errors("<project></project>"), None);
2725
2726 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
2729 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
2730 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
2731
2732 let only_analyser =
2733 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
2734 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
2735 assert!(!updated.contains("ErrorProne"), "{updated}");
2736 }
2737
2738 #[test]
2739 fn vintage_carries_the_engine_version_not_the_platform_one() {
2740 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
2744 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
2745
2746 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";
2747 let updated =
2748 maven_with_vintage(pom, launcher_version(pom)).expect("a JUnit 4 project needs both");
2749 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
2750 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
2751 assert!(
2752 updated.contains("<groupId>org.junit.vintage</groupId>"),
2753 "{updated}"
2754 );
2755 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
2758 assert!(
2759 updated.contains(&format!(
2760 "<version>{}</version>",
2761 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
2762 )),
2763 "{updated}"
2764 );
2765
2766 assert_eq!(
2768 maven_with_vintage(&updated, launcher_version(&updated)),
2769 None
2770 );
2771 }
2772}