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_files(&self) -> usize {
1972 self.project.instrumented.len()
1973 }
1974
1975 pub fn environment(&self) -> BTreeMap<String, String> {
1978 BTreeMap::from([(
1979 "SUPERCOV_JVM_SERVER_EVIDENCE_DIR".into(),
1980 self.directory.join("evidence").display().to_string(),
1981 )])
1982 }
1983
1984 pub fn publish(
1985 self,
1986 root: &Path,
1987 _evidence_run_id: &str,
1988 metadata: RunMetadata,
1989 served: &[crate::served_tests::ServedTest],
1990 diagnostics: &mut dyn Write,
1991 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
1992 crate::owned_server::publish(
1993 root,
1994 crate::owned_server::OwnedServer {
1995 language: "Java/Kotlin",
1996 declaration: jvm_declaration(),
1997 environment: "jvm",
1998 coverage_model: jvm_coverage_model(),
1999 manifest: &self.project.manifest,
2000 probes: &self.project.probes,
2001 widths: &self.project.decision_widths,
2002 integrity: &self.integrity,
2003 source_inputs: &self.source_inputs,
2004 source_roots: self.source_roots.clone(),
2005 directory: &self.directory,
2006 source_files: self.project.instrumented.len(),
2007 },
2008 metadata,
2009 served,
2010 diagnostics,
2011 )
2012 }
2013}
2014
2015#[cfg(test)]
2016mod tests {
2017 use super::*;
2018
2019 #[test]
2020 fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
2021 assert!(asks_for_parallel_execution(Some(
2025 "junit.jupiter.execution.parallel.enabled=true\n"
2026 )));
2027 assert!(asks_for_parallel_execution(Some(
2028 " junit.jupiter.execution.parallel.enabled = True \n"
2029 )));
2030 assert!(!asks_for_parallel_execution(Some(
2031 "junit.jupiter.execution.parallel.enabled=false\n"
2032 )));
2033 assert!(!asks_for_parallel_execution(Some(
2036 "junit.jupiter.execution.parallel.mode.default=concurrent\n"
2037 )));
2038 assert!(!asks_for_parallel_execution(None));
2039 assert!(!asks_for_parallel_execution(Some("")));
2040 }
2041
2042 #[test]
2043 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
2044 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
2048 junit.jupiter.execution.parallel.enabled=true\n\
2049 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
2050 let updated = sequential_properties(Some(existing));
2051 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
2052 assert!(
2053 !updated.contains("parallel.enabled=true"),
2054 "the project's own setting must not survive:\n{updated}"
2055 );
2056 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
2058 assert!(updated.contains("displayname.generator.default"));
2059
2060 assert!(
2062 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
2063 );
2064 }
2065
2066 #[test]
2067 fn gradle_is_told_to_run_the_tests_again() {
2068 let (command, note) = command_with_fresh_results(
2071 JvmBuild::Gradle,
2072 &["./gradlew".to_owned(), "test".to_owned()],
2073 );
2074 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
2075 assert!(note.is_some());
2076
2077 let (command, note) = command_with_fresh_results(
2079 JvmBuild::Gradle,
2080 &[
2081 "./gradlew".to_owned(),
2082 "test".to_owned(),
2083 "--rerun".to_owned(),
2084 ],
2085 );
2086 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
2087 assert!(note.is_none());
2088
2089 let (command, note) =
2091 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
2092 assert_eq!(command, ["mvn", "test"]);
2093 assert!(note.is_none());
2094 }
2095
2096 #[test]
2097 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
2098 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
2101 assert!(
2102 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
2103 "{configuration}"
2104 );
2105 assert!(configuration.contains("PROBES = 7"));
2106 assert!(configuration.contains("new int[] {2, 3}"));
2107 }
2108
2109 #[test]
2110 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
2111 assert_eq!(
2114 class_of("CalculatorTest#zeroIsNamed()"),
2115 Some("CalculatorTest")
2116 );
2117 assert_eq!(
2118 class_of("CalculatorSpec#a sum adds its parts"),
2119 Some("CalculatorSpec")
2120 );
2121 assert_eq!(class_of("Standalone"), Some("Standalone"));
2122 assert_eq!(class_of(""), None);
2123 }
2124
2125 #[test]
2126 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
2127 assert_eq!(
2132 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
2133 .as_deref(),
2134 Some("1.10.2")
2135 );
2136 assert_eq!(
2137 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
2138 .as_deref(),
2139 Some("1.13.1")
2140 );
2141 assert_eq!(
2144 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
2145 None
2146 );
2147 assert_eq!(
2150 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
2151 Some(DEFAULT_LAUNCHER_VERSION)
2152 );
2153 assert_eq!(
2159 launcher_version("<artifactId>parent</artifactId>").as_deref(),
2160 Some(DEFAULT_LAUNCHER_VERSION)
2161 );
2162 assert_eq!(
2163 launcher_version("").as_deref(),
2164 Some(DEFAULT_LAUNCHER_VERSION)
2165 );
2166 }
2167
2168 #[test]
2169 fn junit_is_versioned_through_parents_catalogs_and_junit_6() {
2170 assert_eq!(
2172 launcher_version("<artifactId>junit-jupiter</artifactId><version>6.0.1</version>")
2173 .as_deref(),
2174 Some("6.0.1")
2175 );
2176 assert_eq!(engine_version_of_platform("6.0.1"), "6.0.1");
2177 assert_eq!(
2180 launcher_version("<artifactId>junit-jupiter</artifactId><version>${junit}</version><plugin><version>5.2.1</version></plugin>")
2181 .as_deref(),
2182 Some(DEFAULT_LAUNCHER_VERSION)
2183 );
2184 let catalog = "[versions]\njunit = \"5.11.3\"\n\n[libraries]\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\n";
2186 let updated = gradle_with_launcher("plugins { id 'java' }\n", false, catalog, &[]).unwrap();
2187 assert!(
2188 updated.contains("junit-platform-launcher:1.11.3"),
2189 "{updated}"
2190 );
2191
2192 let root = std::env::temp_dir().join(format!(
2194 "supercov-junit-parents-{}-{}",
2195 std::process::id(),
2196 std::time::SystemTime::now()
2197 .duration_since(std::time::UNIX_EPOCH)
2198 .unwrap()
2199 .as_nanos()
2200 ));
2201 let module = root.join("module");
2202 fs::create_dir_all(&module).unwrap();
2203 let child = "<project><parent><groupId>g</groupId><artifactId>p</artifactId><version>1</version></parent><dependencies><dependency><artifactId>junit-jupiter-api</artifactId></dependency></dependencies></project>";
2204 fs::write(
2205 root.join("pom.xml"),
2206 "<project><artifactId>p</artifactId><dependencyManagement><dependencies><dependency><artifactId>junit-bom</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2207 )
2208 .unwrap();
2209 assert_eq!(maven_launcher_version(&module, child), None);
2210 fs::write(
2211 root.join("pom.xml"),
2212 "<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>",
2213 )
2214 .unwrap();
2215 assert_eq!(
2216 maven_launcher_version(&module, child).as_deref(),
2217 Some("1.14.4")
2218 );
2219 fs::remove_file(root.join("pom.xml")).unwrap();
2221 let unknown = child.replace(
2222 "<artifactId>p</artifactId>",
2223 "<artifactId>not-downloaded</artifactId>",
2224 );
2225 assert_eq!(maven_launcher_version(&module, &unknown), None);
2226 fs::remove_dir_all(&root).ok();
2227 }
2228
2229 #[test]
2230 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
2231 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";
2232 let updated =
2233 maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2234 assert!(updated.contains("junit-platform-launcher"), "{updated}");
2235 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
2236 assert!(
2238 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
2239 "{updated}"
2240 );
2241 assert_eq!(
2243 maven_with_launcher(&updated, launcher_version(&updated)),
2244 None
2245 );
2246
2247 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
2249 let updated =
2250 maven_with_launcher(bare, launcher_version(bare)).expect("a block is created");
2251 assert!(updated.contains("<dependencies>"), "{updated}");
2252 assert!(
2253 updated.find("</dependencies>") < updated.find("</project>"),
2254 "{updated}"
2255 );
2256 }
2257
2258 #[test]
2259 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
2260 let groovy =
2261 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
2262 let updated =
2263 gradle_with_launcher(groovy, false, "", &[]).expect("the launcher is missing");
2264 assert!(
2265 updated
2266 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
2267 "{updated}"
2268 );
2269 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
2271 assert_eq!(gradle_with_launcher(&updated, false, "", &[]), None);
2272
2273 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
2279 let updated = gradle_with_launcher(runtime_only, false, "", &[])
2280 .expect("a runtime-only declaration does not reach the compiler");
2281 assert!(
2282 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
2283 "{updated}"
2284 );
2285 assert!(
2286 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
2287 "the project's own declaration stays:\n{updated}"
2288 );
2289
2290 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
2291 let updated = gradle_with_launcher(kotlin, true, "", &[]).expect("the launcher is missing");
2292 assert!(
2293 updated.contains(
2294 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
2295 ),
2296 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
2297 );
2298 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
2299 }
2300
2301 #[test]
2304 fn a_test_class_named_in_full_finds_its_file() {
2305 let declared_in = BTreeMap::from([
2306 (
2307 "AnnotationsTest".to_owned(),
2308 "src/test/java/com/fasterxml/classmate/AnnotationsTest.java".to_owned(),
2309 ),
2310 (
2311 "CalculatorSpec".to_owned(),
2312 "src/test/kotlin/app/CalculatorSpec.kt".to_owned(),
2313 ),
2314 ]);
2315 assert_eq!(
2316 declaring_file(&declared_in, "com.fasterxml.classmate.AnnotationsTest").as_deref(),
2317 Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2318 );
2319 assert_eq!(
2320 declaring_file(
2321 &declared_in,
2322 "com.fasterxml.classmate.AnnotationsTest$Nested"
2323 )
2324 .as_deref(),
2325 Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2326 );
2327 assert_eq!(
2328 declaring_file(&declared_in, "app.CalculatorSpec").as_deref(),
2329 Some("src/test/kotlin/app/CalculatorSpec.kt")
2330 );
2331 assert_eq!(
2333 declaring_file(&declared_in, "CalculatorSpec").as_deref(),
2334 Some("src/test/kotlin/app/CalculatorSpec.kt")
2335 );
2336 assert_eq!(declaring_file(&declared_in, "org.other.Missing"), None);
2337 }
2338
2339 #[test]
2340 fn a_license_header_check_is_found_through_the_parent_pom() {
2341 let root = std::env::temp_dir().join(format!(
2342 "supercov-rat-{}-{}",
2343 std::process::id(),
2344 std::time::SystemTime::now()
2345 .duration_since(std::time::UNIX_EPOCH)
2346 .unwrap()
2347 .as_nanos()
2348 ));
2349 fs::create_dir_all(root.join("parent")).unwrap();
2350 fs::write(
2351 root.join("parent/pom.xml"),
2352 "<project><artifactId>parent</artifactId><build><plugins><plugin><groupId>org.apache.rat</groupId><artifactId>apache-rat-plugin</artifactId></plugin></plugins></build></project>",
2353 )
2354 .unwrap();
2355 fs::write(
2356 root.join("pom.xml"),
2357 "<project><parent><groupId>g</groupId><artifactId>parent</artifactId><version>1</version><relativePath>parent/pom.xml</relativePath></parent></project>",
2358 )
2359 .unwrap();
2360 assert!(checks_license_headers(&root));
2361 fs::write(
2362 root.join("pom.xml"),
2363 "<project><artifactId>plain</artifactId></project>",
2364 )
2365 .unwrap();
2366 assert!(!checks_license_headers(&root));
2367 fs::remove_dir_all(&root).ok();
2368 }
2369
2370 #[test]
2371 fn only_the_modules_a_command_names_are_expected_to_write_evidence() {
2372 let words = |c: &str| c.split(' ').map(str::to_owned).collect::<Vec<_>>();
2373 assert_eq!(
2374 named_modules(
2375 JvmBuild::Gradle,
2376 &words("./gradlew :moshi:test --tests *Reader*")
2377 ),
2378 Some(BTreeSet::from(["moshi".to_owned()]))
2379 );
2380 assert_eq!(
2381 named_modules(
2382 JvmBuild::Gradle,
2383 &words("gradle :moshi:records-tests:test :test")
2384 ),
2385 Some(BTreeSet::from([
2386 "moshi/records-tests".to_owned(),
2387 ".".to_owned()
2388 ]))
2389 );
2390 assert_eq!(named_modules(JvmBuild::Gradle, &words("gradle test")), None);
2391 assert_eq!(
2392 named_modules(JvmBuild::Gradle, &words("gradle test -x :slow:test")),
2393 None
2394 );
2395 assert_eq!(
2396 named_modules(JvmBuild::Maven, &words("mvn -pl core,api/ test")),
2397 Some(BTreeSet::from(["core".to_owned(), "api".to_owned()]))
2398 );
2399 assert_eq!(
2400 named_modules(JvmBuild::Maven, &words("mvn -pl :core test")),
2401 None
2402 );
2403 assert_eq!(named_modules(JvmBuild::Maven, &words("mvn test")), None);
2404 assert_eq!(
2405 named_modules(JvmBuild::Maven, &words("mvn --projects ./core test")),
2406 Some(BTreeSet::from(["core".to_owned()]))
2407 );
2408 assert_eq!(
2410 named_modules(JvmBuild::Maven, &words("mvn -pl core,!slow test")),
2411 None
2412 );
2413 }
2414
2415 #[test]
2416 fn each_relaxation_is_said_once_however_many_build_files_needed_it() {
2417 let mut relaxed = Vec::new();
2418 for _ in 0..2 {
2420 note_relaxations("-Werror -Xplugin:ErrorProne", "-Xlint:none", &mut relaxed);
2421 }
2422 assert_eq!(
2423 relaxed,
2424 [
2425 "stopped the build failing on warnings",
2426 "switched Error Prone off"
2427 ]
2428 );
2429 let mut maven = Vec::new();
2430 note_relaxations(
2431 "<failOnWarning>true</failOnWarning>",
2432 "<failOnWarning>false</failOnWarning>",
2433 &mut maven,
2434 );
2435 assert_eq!(maven, ["stopped the build failing on warnings"]);
2436 let mut unchanged = Vec::new();
2437 note_relaxations("-Xlint:all", "-Xlint:all", &mut unchanged);
2438 assert!(unchanged.is_empty());
2439 }
2440
2441 #[test]
2442 fn the_platform_version_follows_jupiter_s() {
2443 assert_eq!(platform_of_jupiter("5.13.1").as_deref(), Some("1.13.1"));
2444 assert_eq!(
2445 platform_of_jupiter("5.10.0-M1</version>").as_deref(),
2446 Some("1.10.0-M1")
2447 );
2448 assert_eq!(platform_of_jupiter("6.0.1").as_deref(), Some("6.0.1"));
2449 for unknown in [
2450 "${junit.version}",
2451 "5.10",
2452 "5..1",
2453 "5.x.1",
2454 "4.13.2",
2455 "five.1.0",
2456 ] {
2457 assert_eq!(platform_of_jupiter(unknown), None, "{unknown}");
2458 }
2459 }
2460
2461 #[test]
2462 fn gradle_moves_junit_4_projects_onto_the_platform_through_vintage() {
2463 let kotlin = "dependencies {\n testImplementation(\"junit:junit:4.13.2\")\n}\n";
2464 let updated = gradle_with_launcher(kotlin, true, "", &[".".into(), "moshi".into()])
2465 .expect("a JUnit 4 project needs Vintage");
2466 assert!(
2467 updated
2468 .contains("\"testRuntimeOnly\"(\"org.junit.vintage:junit-vintage-engine:5.10.2\")"),
2469 "{updated}"
2470 );
2471 assert!(updated.contains("in listOf(\"\", \"moshi\")"), "{updated}");
2472 assert!(
2473 updated.contains("if (options is JUnitOptions) useJUnitPlatform()"),
2474 "only a task still on Gradle's JUnit 4 framework is switched:\n{updated}"
2475 );
2476
2477 let groovy = "dependencies {\n testImplementation 'junit:junit:4.13.2'\n testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'\n}\n";
2479 let updated = gradle_with_launcher(groovy, false, "", &["core".into()])
2480 .expect("a JUnit 4 project needs Vintage");
2481 assert!(
2482 updated.contains("testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2'"),
2483 "{updated}"
2484 );
2485 assert!(updated.contains("in [\"core\"]"), "{updated}");
2486 assert!(
2487 updated.contains("tasks.withType(Test).configureEach"),
2488 "{updated}"
2489 );
2490 assert!(
2491 !updated.contains("\n testImplementation 'org.junit.platform"),
2492 "the launcher is already there:\n{updated}"
2493 );
2494 }
2495
2496 #[test]
2497 fn only_the_listeners_a_project_can_compile_are_written() {
2498 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
2502 assert!(junit.platform && !junit.testng);
2503
2504 let testng = frameworks("<artifactId>testng</artifactId>");
2505 assert!(testng.testng && !testng.platform);
2506
2507 let both = frameworks("testng ... junit-jupiter");
2510 assert!(both.platform && both.testng);
2511
2512 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
2515 assert!(frameworks("org.spockframework:spock-core").platform);
2516
2517 let unknown = frameworks("<artifactId>demo</artifactId>");
2520 assert!(unknown.platform && !unknown.testng);
2521 }
2522
2523 #[test]
2524 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
2525 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
2531 assert!(four.junit4 && !four.platform && !four.testng);
2532
2533 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
2534 assert!(five.platform && !five.junit4);
2535
2536 let both = frameworks(
2539 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
2540 );
2541 assert!(both.platform && !both.junit4);
2542
2543 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
2545 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
2546 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
2547 }
2548
2549 #[test]
2550 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
2551 let root = std::env::temp_dir().join(format!(
2558 "supercov-jpms-{}-{}",
2559 std::process::id(),
2560 std::time::SystemTime::now()
2561 .duration_since(std::time::UNIX_EPOCH)
2562 .unwrap()
2563 .as_nanos()
2564 ));
2565 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();
2566 for module in ["lib", "boundaries"] {
2567 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
2568 write(
2569 &root.join(module).join("src/main/java/app/Api.java"),
2570 "package app;\npublic class Api { public int one() { return 1; } }\n",
2571 )
2572 .unwrap();
2573 write(
2574 &root.join(module).join("src/test/java/app/ApiTest.java"),
2575 "package app;\nclass ApiTest { void t() {} }\n",
2576 )
2577 .unwrap();
2578 }
2579 write(
2580 &root.join("boundaries/src/test/java/module-info.java"),
2581 "module app.boundaries {\n requires app.lib;\n}\n",
2582 )
2583 .unwrap();
2584
2585 let evidence = root.join("evidence");
2586 let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
2587 assert_eq!(instrumented.modular, ["boundaries"]);
2588
2589 assert!(
2591 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
2592 .exists()
2593 );
2594 for name in ["SupercovListener.java", "SupercovConfig.java"] {
2595 assert!(
2596 !root
2597 .join("boundaries/src/test/java/com/supercorp/supercov")
2598 .join(name)
2599 .exists(),
2600 "{name} must not be written into a named module"
2601 );
2602 }
2603 assert!(
2604 !root
2605 .join("boundaries/src/test/resources")
2606 .join(SERVICES_FILE)
2607 .exists(),
2608 "and nothing registers a listener that is not there"
2609 );
2610 assert!(
2614 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
2615 .exists()
2616 );
2617
2618 fs::remove_dir_all(root).ok();
2619 }
2620
2621 #[test]
2622 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
2623 let root = std::env::temp_dir().join(format!(
2628 "supercov-catalog-{}-{}",
2629 std::process::id(),
2630 std::time::SystemTime::now()
2631 .duration_since(std::time::UNIX_EPOCH)
2632 .unwrap()
2633 .as_nanos()
2634 ));
2635 write(
2636 &root.join("gradle/libs.versions.toml"),
2637 "[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",
2638 )
2639 .unwrap();
2640 let catalog = version_catalog(&root);
2641
2642 let junit4 = frameworks(&with_catalog(
2643 "dependencies { testImplementation(libs.junit) }",
2644 &catalog,
2645 ));
2646 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
2647
2648 let reflect = frameworks(&with_catalog(
2650 "dependencies { testImplementation(libs.kotlin.reflect) }",
2651 &catalog,
2652 ));
2653 assert!(!reflect.junit4, "{reflect:?}");
2654
2655 let jupiter = frameworks(&with_catalog(
2656 "dependencies { testImplementation(libs.jupiter) }",
2657 &catalog,
2658 ));
2659 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
2660
2661 let bundle = frameworks(&with_catalog(
2663 "dependencies { testImplementation(libs.bundles.unit) }",
2664 &catalog,
2665 ));
2666 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
2667
2668 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
2670 assert_eq!(with_catalog(plain, &catalog), plain);
2671
2672 fs::remove_dir_all(root).ok();
2673 }
2674
2675 #[test]
2676 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
2677 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";
2683 let updated =
2684 maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2685 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
2686 let managed_end = updated
2687 .find("</dependencyManagement>")
2688 .expect("managed block");
2689 let build_start = updated.find("<build>").expect("build block");
2690 assert!(
2691 at > managed_end,
2692 "not among the managed versions:\n{updated}"
2693 );
2694 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
2695 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
2698 }
2699
2700 #[test]
2701 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
2702 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";
2708 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
2709 assert!(
2710 updated.contains("<failOnWarning>false</failOnWarning>"),
2711 "{updated}"
2712 );
2713 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
2714 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
2717
2718 assert_eq!(without_warnings_as_errors("<project></project>"), None);
2720
2721 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
2724 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
2725 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
2726
2727 let only_analyser =
2728 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
2729 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
2730 assert!(!updated.contains("ErrorProne"), "{updated}");
2731 }
2732
2733 #[test]
2734 fn vintage_carries_the_engine_version_not_the_platform_one() {
2735 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
2739 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
2740
2741 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";
2742 let updated =
2743 maven_with_vintage(pom, launcher_version(pom)).expect("a JUnit 4 project needs both");
2744 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
2745 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
2746 assert!(
2747 updated.contains("<groupId>org.junit.vintage</groupId>"),
2748 "{updated}"
2749 );
2750 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
2753 assert!(
2754 updated.contains(&format!(
2755 "<version>{}</version>",
2756 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
2757 )),
2758 "{updated}"
2759 );
2760
2761 assert_eq!(
2763 maven_with_vintage(&updated, launcher_version(&updated)),
2764 None
2765 );
2766 }
2767}