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 without_warnings_as_errors(build_file: &str) -> Option<String> {
925 let mut updated = build_file.to_owned();
926 for (from, to) in [
927 (
928 "<failOnWarning>true</failOnWarning>",
929 "<failOnWarning>false</failOnWarning>",
930 ),
931 (
932 "<failOnWarnings>true</failOnWarnings>",
933 "<failOnWarnings>false</failOnWarnings>",
934 ),
935 ("<arg>-Werror</arg>", ""),
936 ("<compilerArgument>-Werror</compilerArgument>", ""),
937 ("options.compilerArgs << '-Werror'", ""),
938 ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
939 ] {
940 updated = updated.replace(from, to);
941 }
942 updated = without_error_prone(&updated);
943 (updated != build_file).then_some(updated)
944}
945
946fn without_error_prone(build_file: &str) -> String {
958 let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
959 return build_file.to_owned();
960 };
961 let Some(end) = build_file[start..].find("</arg>") else {
962 return build_file.to_owned();
963 };
964 let mut updated = build_file.to_owned();
965 updated.replace_range(start..start + end + "</arg>".len(), "");
966 updated
967}
968
969fn command_with_fresh_results(
974 build: JvmBuild,
975 command: &[String],
976) -> (Vec<String>, Option<String>) {
977 let mut updated = command.to_vec();
978 match build {
979 JvmBuild::Gradle => {
980 if updated
981 .iter()
982 .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
983 {
984 return (updated, None);
985 }
986 updated.push("--rerun-tasks".into());
987 (
988 updated,
989 Some(
990 "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
991 .into(),
992 ),
993 )
994 }
995 JvmBuild::Maven | JvmBuild::Plain => (updated, None),
998 }
999}
1000
1001#[derive(Debug, Clone)]
1010struct JvmModule {
1011 directory: String,
1013 evidence: PathBuf,
1017 has_tests: bool,
1018}
1019
1020struct InstrumentedWorkspace {
1021 project: PreparedJvmProject,
1022 build: JvmBuild,
1023 modules: Vec<JvmModule>,
1024 declared_in: BTreeMap<String, String>,
1026 added_launcher: Option<&'static str>,
1028 serialised: BTreeSet<String>,
1032 concurrent: BTreeSet<String>,
1035 added_vintage: bool,
1038 relaxed: Vec<&'static str>,
1041 license_check: bool,
1044 unmeasurable: Vec<String>,
1047 modular: Vec<String>,
1049}
1050
1051fn module_of(relative: &str) -> String {
1059 match relative.find("src/") {
1060 Some(0) | None => ".".to_owned(),
1061 Some(at) => relative[..at].trim_end_matches('/').to_owned(),
1062 }
1063}
1064
1065fn class_of(test_name: &str) -> Option<&str> {
1068 test_name.split('#').next().filter(|name| !name.is_empty())
1069}
1070
1071fn declaring_file(declared_in: &BTreeMap<String, String>, class: &str) -> Option<String> {
1079 let top = class.split('$').next().unwrap_or(class);
1080 let path = top.replace('.', "/");
1081 let simple = top.rsplit('.').next().unwrap_or(top);
1082 let by_path = declared_in.values().find(|file| {
1083 file.rsplit_once('.').is_some_and(|(stem, _)| {
1084 stem.ends_with(&path) && stem[..stem.len() - path.len()].ends_with('/') || stem == path
1085 })
1086 });
1087 by_path.or_else(|| declared_in.get(simple)).cloned()
1088}
1089
1090fn instrument_workspace(
1091 workspace: &Path,
1092 evidence_directory: &Path,
1093 exact_attribution: bool,
1094 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
1095) -> Result<InstrumentedWorkspace, String> {
1096 let project = prepare_jvm_project(workspace, roots)?;
1097 let build = detect_build(workspace);
1098
1099 for (relative, instrumented) in &project.instrumented {
1100 write(&workspace.join(relative), instrumented)?;
1101 }
1102
1103 let probe_count = project
1104 .probes
1105 .keys()
1106 .max()
1107 .map_or(0, |highest| *highest as usize + 1);
1108
1109 let catalog = version_catalog(workspace);
1115 let frameworks_of = |directory: &str| -> Frameworks {
1116 let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
1117 let mut text = names
1118 .iter()
1119 .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
1120 .unwrap_or_default();
1121 for name in names {
1122 if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
1123 text.push('\n');
1124 text.push_str(&own);
1125 break;
1126 }
1127 }
1128 frameworks(&with_catalog(&text, &catalog))
1129 };
1130 let mut unmeasurable: Vec<String> = Vec::new();
1131 let mut modular: Vec<String> = Vec::new();
1132
1133 let mut modules: BTreeMap<String, bool> = BTreeMap::new();
1136 for (relative, _) in &project.files.sources {
1137 modules.entry(module_of(relative)).or_insert(false);
1138 }
1139 for (relative, _) in &project.files.tests {
1140 *modules.entry(module_of(relative)).or_default() = true;
1141 }
1142 for module in modules.keys().cloned().collect::<Vec<_>>() {
1149 if workspace.join(&module).join("src/test").is_dir() {
1150 modules.insert(module, true);
1151 }
1152 }
1153 if modules.is_empty() {
1154 modules.insert(".".to_owned(), true);
1155 }
1156
1157 let modules = modules
1158 .into_iter()
1159 .map(|(directory, has_tests)| JvmModule {
1160 evidence: evidence_directory.join(
1161 directory
1162 .chars()
1163 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1164 .collect::<String>(),
1165 ),
1166 directory,
1167 has_tests,
1168 })
1169 .collect::<Vec<_>>();
1170
1171 for module in &modules {
1172 let frameworks = frameworks_of(&module.directory);
1173 let at = |source_set: &str| {
1174 workspace
1175 .join(&module.directory)
1176 .join(source_root(source_set))
1177 .join(PACKAGE_DIRECTORY)
1178 };
1179 write(
1184 &at("main").join("Supercov.java"),
1185 &runtime_source(probe_count),
1186 )?;
1187 if !module.has_tests {
1188 continue;
1189 }
1190 if frameworks.junit4 && build == JvmBuild::Plain {
1191 unmeasurable.push(module.directory.clone());
1196 continue;
1197 }
1198 if workspace
1208 .join(&module.directory)
1209 .join(source_root("test"))
1210 .join("module-info.java")
1211 .exists()
1212 {
1213 modular.push(module.directory.clone());
1214 continue;
1215 }
1216 let test = at("test");
1219 write(
1220 &test.join("SupercovConfig.java"),
1221 &configuration(probe_count, &project.decision_widths, &module.evidence),
1222 )?;
1223 if frameworks.platform || frameworks.junit4 {
1224 write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
1225 }
1226 if frameworks.testng {
1227 write(
1228 &test.join("SupercovTestNGListener.java"),
1229 TESTNG_LISTENER_SOURCE,
1230 )?;
1231 }
1232 }
1233
1234 let mut relaxed: Vec<&'static str> = Vec::new();
1237 for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
1238 let path = workspace.join(name);
1239 let Ok(existing) = fs::read_to_string(&path) else {
1240 continue;
1241 };
1242 let Some(updated) = without_warnings_as_errors(&existing) else {
1243 continue;
1244 };
1245 if !relaxed.contains(&"stopped the build failing on warnings")
1249 && updated.contains("<failOnWarning>false</failOnWarning>")
1250 != existing.contains("<failOnWarning>false</failOnWarning>")
1251 || existing.contains("-Werror") && !updated.contains("-Werror")
1252 {
1253 relaxed.push("stopped the build failing on warnings");
1254 }
1255 if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
1256 relaxed.push("switched Error Prone off");
1257 }
1258 write(&path, &updated)?;
1259 }
1260 relaxed.dedup();
1261 let license_check = build == JvmBuild::Maven && checks_license_headers(workspace);
1262
1263 let mut added_launcher = None;
1268 let mut serialised: BTreeSet<String> = BTreeSet::new();
1269 let mut concurrent: BTreeSet<String> = BTreeSet::new();
1270 let mut added_vintage = false;
1271 match build {
1272 JvmBuild::Maven => {
1276 for module in modules
1277 .iter()
1278 .filter(|module| {
1279 module.has_tests
1280 && !unmeasurable.contains(&module.directory)
1281 && !modular.contains(&module.directory)
1282 })
1283 .filter(|module| {
1287 let frameworks = frameworks_of(&module.directory);
1288 frameworks.platform || frameworks.junit4
1289 })
1290 {
1291 let pom = workspace.join(&module.directory).join("pom.xml");
1292 let Ok(existing) = fs::read_to_string(&pom) else {
1293 continue;
1294 };
1295 let platform =
1299 maven_launcher_version(&workspace.join(&module.directory), &existing);
1300 let updated = if frameworks_of(&module.directory).junit4 {
1301 added_vintage = true;
1302 maven_with_vintage(&existing, platform)
1303 } else {
1304 maven_with_launcher(&existing, platform)
1305 };
1306 if let Some(updated) = updated {
1307 write(&pom, &updated)?;
1308 added_launcher = Some("pom.xml");
1309 }
1310 }
1311 }
1312 JvmBuild::Gradle
1313 if !modules.iter().any(|module| {
1314 let frameworks = frameworks_of(&module.directory);
1315 module.has_tests && (frameworks.platform || frameworks.junit4)
1316 }) => {}
1317 JvmBuild::Gradle => {
1318 let junit4 = modules
1321 .iter()
1322 .filter(|module| {
1323 let frameworks = frameworks_of(&module.directory);
1324 module.has_tests
1325 && frameworks.junit4
1326 && !frameworks.testng
1327 && !modular.contains(&module.directory)
1328 })
1329 .map(|module| module.directory.clone())
1330 .collect::<Vec<_>>();
1331 for name in ["build.gradle.kts", "build.gradle"] {
1332 let path = workspace.join(name);
1333 let Ok(existing) = fs::read_to_string(&path) else {
1334 continue;
1335 };
1336 let catalog = fs::read_to_string(workspace.join("gradle/libs.versions.toml"))
1337 .unwrap_or_default();
1338 if let Some(updated) =
1339 gradle_with_launcher(&existing, name.ends_with(".kts"), &catalog, &junit4)
1340 {
1341 added_vintage |= !junit4.is_empty();
1342 write(&path, &updated)?;
1343 added_launcher = Some(if name.ends_with(".kts") {
1344 "build.gradle.kts"
1345 } else {
1346 "build.gradle"
1347 });
1348 }
1349 break;
1350 }
1351 }
1352 JvmBuild::Plain => {}
1355 }
1356
1357 for module in modules.iter().filter(|module| {
1358 module.has_tests
1359 && !unmeasurable.contains(&module.directory)
1360 && !modular.contains(&module.directory)
1361 }) {
1362 let frameworks = frameworks_of(&module.directory);
1363 let resources = workspace.join(&module.directory).join("src/test/resources");
1364 if frameworks.platform || frameworks.junit4 {
1365 write(
1366 &resources.join(SERVICES_FILE),
1367 &format!("{LISTENER_CLASS}\n"),
1368 )?;
1369 let properties = resources.join("junit-platform.properties");
1370 let existing = fs::read_to_string(&properties).ok();
1371 if !exact_attribution {
1372 if asks_for_parallel_execution(existing.as_deref()) {
1378 concurrent.insert(module.directory.clone());
1379 }
1380 continue;
1381 }
1382 if asks_for_parallel_execution(existing.as_deref()) {
1389 serialised.insert(module.directory.clone());
1390 }
1391 write(&properties, &sequential_properties(existing.as_deref()))?;
1392 }
1393 if frameworks.testng {
1394 write(
1395 &resources.join(TESTNG_SERVICES_FILE),
1396 &format!("{TESTNG_LISTENER_CLASS}\n"),
1397 )?;
1398 }
1399 }
1400
1401 let mut declared_in = BTreeMap::new();
1405 for (relative, _) in &project.files.tests {
1406 if let Some(stem) = relative
1407 .rsplit('/')
1408 .next()
1409 .and_then(|name| name.split('.').next())
1410 {
1411 declared_in.insert(stem.to_owned(), relative.clone());
1412 }
1413 }
1414
1415 Ok(InstrumentedWorkspace {
1416 project,
1417 build,
1418 modules,
1419 declared_in,
1420 added_launcher,
1421 serialised,
1422 concurrent,
1423 added_vintage,
1424 relaxed,
1425 license_check,
1426 unmeasurable,
1427 modular,
1428 })
1429}
1430
1431pub fn current_jvm_integrity(
1433 root: &Path,
1434 command: &[String],
1435 source_roots: Option<&[String]>,
1436) -> Result<crate::run_store::RunIntegrity, String> {
1437 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1438 let files = crate::jvm_project::discover_jvm_files(&root)?;
1439 let mut inputs = jvm_integrity_inputs(&files, command);
1440 crate::source_discovery::fold_roots_into_configuration(
1441 &mut inputs.execution_configuration,
1442 source_roots,
1443 );
1444 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_jvm())
1445 .map_err(|error| error.to_string())
1446}
1447
1448pub fn run_direct_jvm(
1449 request: &DirectJvmRunRequest,
1450 diagnostics: &mut dyn Write,
1451) -> Result<DirectJvmRunResult, String> {
1452 if request.command.is_empty() {
1453 return Err("test command must not be empty".into());
1454 }
1455 let total_started = Instant::now();
1456 let initialization_started = Instant::now();
1457 let root = canonicalize_simplified(&request.root)
1458 .map_err(|error| format!("{}: {error}", request.root.display()))?;
1459 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1460 .map_err(|error| error.to_string())?;
1461 let initialization_ms = elapsed_ms(initialization_started);
1462 let work_directory = root.join(".supercov/work").join(&request.run_id);
1463 let measured = std::cell::Cell::new(false);
1467 let result = (|| {
1468 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1469 .map_err(|error| error.to_string())?;
1470 if !recovered_runs.is_empty() {
1471 writeln!(
1472 diagnostics,
1473 "[supercov] recovered abandoned run(s): {}",
1474 recovered_runs.join(", ")
1475 )
1476 .map_err(|error| error.to_string())?;
1477 }
1478
1479 let adapter_started = Instant::now();
1480 let files = crate::jvm_project::discover_jvm_files(&root)?;
1481 let source_roots = crate::source_discovery::configured_source_roots(
1482 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1483 );
1484 let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1485 crate::source_discovery::fold_roots_into_configuration(
1486 &mut integrity_inputs.execution_configuration,
1487 source_roots.as_deref(),
1488 );
1489 let source_inputs =
1490 crate::source_capture::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1491 let integrity = create_explicit_run_integrity(
1492 &root,
1493 &integrity_inputs,
1494 &FrontendIntegrityInputs::embedded_jvm(),
1495 )
1496 .map_err(|error| error.to_string())?;
1497
1498 let workspace_started = Instant::now();
1499 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1500 let workspace =
1501 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1502 let evidence_directory = work_directory.join("jvm");
1503 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1504 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1505 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1506 &root, &workspace, &ambient,
1507 )
1508 .map_err(|error| error.to_string())?;
1509 let instrumented = instrument_workspace(
1510 &workspace,
1511 &evidence_directory,
1512 request.exact_attribution,
1513 roots.as_ref(),
1514 )?;
1515 let workspace_preparation_ms = elapsed_ms(workspace_started);
1516 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1517 writeln!(
1518 diagnostics,
1519 "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1520 match instrumented.build {
1521 JvmBuild::Maven => "a Maven project",
1522 JvmBuild::Gradle => "a Gradle project",
1523 JvmBuild::Plain => "Java/Kotlin sources",
1524 },
1525 instrumented.project.instrumented.len(),
1526 workspace.display()
1527 )
1528 .map_err(|error| error.to_string())?;
1529 if let Some(build_file) = instrumented.added_launcher {
1530 writeln!(
1531 diagnostics,
1532 "[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."
1533 )
1534 .map_err(|error| error.to_string())?;
1535 }
1536 if !instrumented.serialised.is_empty() {
1537 writeln!(
1538 diagnostics,
1539 "[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.",
1540 instrumented
1541 .serialised
1542 .iter()
1543 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1544 .collect::<Vec<_>>()
1545 .join(", ")
1546 )
1547 .map_err(|error| error.to_string())?;
1548 }
1549 if !instrumented.concurrent.is_empty() {
1550 writeln!(
1551 diagnostics,
1552 "[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.",
1553 instrumented
1554 .concurrent
1555 .iter()
1556 .map(|module| if module.is_empty() { "." } else { module.as_str() })
1557 .collect::<Vec<_>>()
1558 .join(", ")
1559 )
1560 .map_err(|error| error.to_string())?;
1561 }
1562 if !instrumented.relaxed.is_empty() {
1563 writeln!(
1564 diagnostics,
1565 "[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.",
1566 instrumented.relaxed.join("; ")
1567 )
1568 .map_err(|error| error.to_string())?;
1569 }
1570 if instrumented.added_vintage {
1571 writeln!(
1572 diagnostics,
1573 "[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."
1574 )
1575 .map_err(|error| error.to_string())?;
1576 }
1577 if !instrumented.modular.is_empty() {
1578 writeln!(
1579 diagnostics,
1580 "[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.",
1581 instrumented.modular.len(),
1582 instrumented.modular.join(", ")
1583 )
1584 .map_err(|error| error.to_string())?;
1585 }
1586 if !instrumented.unmeasurable.is_empty() {
1587 writeln!(
1588 diagnostics,
1589 "[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.",
1590 instrumented.unmeasurable.len(),
1591 instrumented.unmeasurable.join(", ")
1592 )
1593 .map_err(|error| error.to_string())?;
1594 }
1595 for (file, reason) in &instrumented.project.unparseable {
1596 writeln!(
1597 diagnostics,
1598 "[supercov] could not parse {file}: {reason}; it carries no obligations"
1599 )
1600 .map_err(|error| error.to_string())?;
1601 }
1602
1603 let (mut command, note) = command_with_fresh_results(instrumented.build, &request.command);
1604 if instrumented.license_check && !command.iter().any(|a| a.contains("rat.skip")) {
1608 command.push("-Drat.skip=true".into());
1609 writeln!(
1610 diagnostics,
1611 "[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."
1612 )
1613 .map_err(|error| error.to_string())?;
1614 }
1615 if let Some(note) = note {
1616 writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1617 }
1618 let test_started = Instant::now();
1619 let plan = ExecutionPlan {
1620 preparation: Vec::new(),
1621 test: ExecutionPhase {
1622 name: "test".into(),
1623 kind: PhaseKind::Test,
1624 command: CommandSpec {
1625 program: command[0].clone().into(),
1626 arguments: command[1..].iter().map(OsString::from).collect(),
1627 cwd: workspace.clone(),
1628 environment: None,
1629 captured_output: None,
1630 console: Some(crate::workspace::console_names(&root, &workspace)),
1631 },
1632 },
1633 };
1634 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1635 let before = crate::workspace::workspace_output_baseline(&workspace)
1638 .map_err(|error| error.to_string())?;
1639 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1640 .map_err(|error| error.to_string())?;
1641 let test_command_ms = elapsed_ms(test_started);
1642 let rewritten = instrumented
1647 .project
1648 .files
1649 .sources
1650 .iter()
1651 .chain(&instrumented.project.files.tests)
1652 .map(|(file, _)| PathBuf::from(file))
1653 .collect::<BTreeSet<_>>();
1654 let outputs = crate::workspace::sync_outputs_of(
1655 crate::workspace::CommandOutputs::Jvm,
1656 &root,
1657 &workspace,
1658 &before,
1659 &rewritten,
1660 )
1661 .map_err(|error| error.to_string())?;
1662 for line in crate::workspace::describe_command_outputs(&outputs)
1665 .into_iter()
1666 .filter(|line| !line.contains("stayed in the isolated workspace"))
1667 {
1668 writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
1669 }
1670 if let Some(signal) = execution.interrupted_signal {
1671 return Err(format!(
1672 "the test command was interrupted by {signal:?}; no run was published"
1673 ));
1674 }
1675 let exit_code = execution.exit_code;
1676
1677 let publication_started = Instant::now();
1678 let mut parts = Vec::new();
1680 let mut outcomes = Vec::new();
1681 let mut silent = Vec::new();
1682 let named = named_modules(instrumented.build, &request.command);
1683 for module in instrumented
1684 .modules
1685 .iter()
1686 .filter(|module| module.has_tests)
1687 {
1688 let mut written = fs::read_dir(&module.evidence)
1690 .map(|entries| {
1691 entries
1692 .flatten()
1693 .map(|entry| entry.path())
1694 .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1695 .collect::<Vec<_>>()
1696 })
1697 .unwrap_or_default();
1698 written.sort();
1699 if written.is_empty() {
1700 if named
1703 .as_ref()
1704 .is_none_or(|named| named.contains(&module.directory))
1705 {
1706 silent.push(module.directory.clone());
1707 }
1708 continue;
1709 }
1710 let mut forked = Vec::new();
1711 for path in &written {
1712 let bytes =
1713 fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1714 forked.push(
1715 read_evidence(&bytes)
1716 .map_err(|error| format!("{}: {error}", path.display()))?,
1717 );
1718 }
1719 let evidence = merge_evidence(forked);
1720 for test in &evidence.tests {
1721 outcomes.push(OwnedTestOutcome {
1722 name: test.name.clone(),
1723 runner: test.runner.clone(),
1724 package: module.directory.clone(),
1728 file: class_of(&test.name)
1729 .and_then(|class| declaring_file(&instrumented.declared_in, class)),
1730 status: test.status.clone(),
1731 attributed: !test.unattributed,
1737 });
1738 }
1739 parts.push(evidence);
1740 }
1741 if !silent.is_empty() {
1742 writeln!(
1743 diagnostics,
1744 "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1745 silent.len(),
1746 silent.join(", ")
1747 )
1748 .map_err(|error| error.to_string())?;
1749 }
1750 if outcomes.is_empty() {
1751 return Err(format!(
1752 "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."
1753 ));
1754 }
1755 let evidence = merge_evidence(parts);
1756 measured.set(true);
1757 let run = build_frontend_run(OwnedRunInputs {
1758 declaration: jvm_declaration(),
1759 environment: "jvm",
1760 manifest: &instrumented.project.manifest,
1761 probes: &instrumented.project.probes,
1762 evidence: &evidence,
1763 outcomes: &outcomes,
1764 run_id: &request.run_id,
1765 generated_at: &request.started_at,
1766 test_exit_code: exit_code,
1767 coverage_model: jvm_coverage_model(),
1768 })
1769 .map_err(|error| error.to_string())?;
1770 validate_frontend_report_request(&run.declaration, &run.request)
1771 .map_err(|error| error.to_string())?;
1772 let archive_path = work_directory.join("evidence.raw.gz");
1773 let raw = write_archive(
1774 crate::source_capture::append(
1775 run.archive_entries().map_err(|error| error.to_string())?,
1776 &source_inputs,
1777 )?,
1778 &archive_path,
1779 )
1780 .map_err(|error| error.to_string())?;
1781 let evidence_publication_ms = elapsed_ms(publication_started);
1782
1783 let timings = RunTimings {
1784 initialization_ms,
1785 workspace_preparation_ms,
1786 adapter_setup_ms,
1787 instrumented_build_ms: 0.0,
1788 test_command_ms,
1789 evidence_publication_ms,
1790 };
1791 let metadata = RunMetadata {
1792 id: request.run_id.clone(),
1793 started_at: request.started_at.clone(),
1794 duration_ms: elapsed_ms(total_started),
1795 command: request.command.clone(),
1796 test_exit_code: Some(exit_code),
1797 integrity,
1798 raw_evidence: RawEvidenceMetadata {
1799 schema_version: raw.schema_version,
1800 format: raw.format.into(),
1801 file: raw.file.into(),
1802 files: raw.files,
1803 uncompressed_bytes: raw.uncompressed_bytes,
1804 compressed_bytes: raw.compressed_bytes,
1805 },
1806 isolated_build: Some(true),
1807 instrumented_build_cache: None,
1808 timings: Some(timings),
1809 merged: None,
1810 parents: None,
1811 source_roots: source_roots.clone(),
1812 copy: Some(lock.copy()),
1813 merged_across: None,
1814 recorded_by: None,
1815 companions: None,
1816 };
1817 let run_directory =
1818 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1819 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1820 Ok(DirectJvmRunResult {
1821 run_id: request.run_id.clone(),
1822 run_directory,
1823 exit_code,
1824 tests: outcomes
1825 .iter()
1826 .map(|outcome| outcome.name.as_str())
1827 .collect::<BTreeSet<_>>()
1828 .len(),
1829 source_files: instrumented.project.instrumented.len(),
1830 modules: instrumented.modules.len(),
1831 build: instrumented.build,
1832 recovered_runs,
1833 metadata,
1834 })
1835 })();
1836 let result = result.map_err(|error| {
1837 if !measured.get() {
1838 return error;
1839 }
1840 note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1841 });
1842 if result.is_err() {
1843 let _ = remove_stored_tree_deferred(&root, &work_directory);
1844 }
1845 let release = lock.release().map_err(|error| error.to_string());
1846 match (result, release) {
1847 (Ok(result), Ok(())) => Ok(result),
1848 (Err(error), _) => Err(error),
1849 (Ok(_), Err(error)) => Err(error),
1850 }
1851}
1852
1853fn server_runtime_source(probe_count: usize, widths: &[u8]) -> String {
1857 let marker = "static final int[] SERVER_WIDTHS = new int[0]; // supercov:server-widths";
1858 debug_assert!(
1859 RUNTIME_SOURCE.contains(marker),
1860 "the runtime no longer declares the server widths Supercov substitutes"
1861 );
1862 let widths = widths
1863 .iter()
1864 .map(u8::to_string)
1865 .collect::<Vec<_>>()
1866 .join(", ");
1867 runtime_source(probe_count).replace(
1868 marker,
1869 &format!(
1870 "static final int[] SERVER_WIDTHS = new int[] {{{widths}}}; // supercov:server-widths"
1871 ),
1872 )
1873}
1874
1875pub struct JvmServer {
1883 project: PreparedJvmProject,
1884 integrity: crate::run_store::RunIntegrity,
1885 source_inputs: crate::source_manifest::Inputs,
1886 source_roots: Option<Vec<String>>,
1887 directory: PathBuf,
1888}
1889
1890impl JvmServer {
1891 pub fn prepare(
1894 root: &Path,
1895 workspace: &Path,
1896 run_id: &str,
1897 command: &[String],
1898 ) -> Result<Option<Self>, String> {
1899 let files = crate::jvm_project::discover_jvm_files(root)?;
1900 if files.sources.is_empty() {
1901 return Ok(None);
1902 }
1903 let ambient = std::env::vars().collect::<BTreeMap<_, _>>();
1904 let Ok(roots) = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1906 root, workspace, &ambient,
1907 ) else {
1908 return Ok(None);
1909 };
1910 let source_roots = crate::source_discovery::configured_source_roots(&ambient);
1911 let mut integrity_inputs = jvm_integrity_inputs(&files, command);
1912 crate::source_discovery::fold_roots_into_configuration(
1913 &mut integrity_inputs.execution_configuration,
1914 source_roots.as_deref(),
1915 );
1916 let source_inputs =
1917 crate::source_capture::capture(root, "jvm", integrity_inputs.assertion_paths())?;
1918 let integrity = create_explicit_run_integrity(
1919 root,
1920 &integrity_inputs,
1921 &FrontendIntegrityInputs::embedded_jvm(),
1922 )
1923 .map_err(|error| error.to_string())?;
1924 let project = prepare_jvm_project(workspace, roots.as_ref())?;
1925 if project.instrumented.is_empty() {
1926 return Ok(None);
1927 }
1928 for (relative, instrumented) in &project.instrumented {
1929 write(&workspace.join(relative), instrumented)?;
1930 }
1931 let probe_count = project
1932 .probes
1933 .keys()
1934 .max()
1935 .map_or(0, |highest| *highest as usize + 1);
1936 let runtime = server_runtime_source(probe_count, &project.decision_widths);
1937 let modules = project
1938 .files
1939 .sources
1940 .iter()
1941 .map(|(relative, _)| module_of(relative))
1942 .collect::<BTreeSet<_>>();
1943 for module in &modules {
1944 write(
1945 &workspace
1946 .join(module)
1947 .join(source_root("main"))
1948 .join(PACKAGE_DIRECTORY)
1949 .join("Supercov.java"),
1950 &runtime,
1951 )?;
1952 }
1953 let directory = root.join(".supercov/work").join(run_id).join("jvm");
1954 fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
1955 Ok(Some(Self {
1956 project,
1957 integrity,
1958 source_inputs,
1959 source_roots,
1960 directory,
1961 }))
1962 }
1963
1964 pub fn source_files(&self) -> usize {
1965 self.project.instrumented.len()
1966 }
1967
1968 pub fn environment(&self) -> BTreeMap<String, String> {
1971 BTreeMap::from([(
1972 "SUPERCOV_JVM_SERVER_EVIDENCE_DIR".into(),
1973 self.directory.join("evidence").display().to_string(),
1974 )])
1975 }
1976
1977 pub fn publish(
1978 self,
1979 root: &Path,
1980 _evidence_run_id: &str,
1981 metadata: RunMetadata,
1982 served: &[crate::served_tests::ServedTest],
1983 diagnostics: &mut dyn Write,
1984 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
1985 crate::owned_server::publish(
1986 root,
1987 crate::owned_server::OwnedServer {
1988 language: "Java/Kotlin",
1989 declaration: jvm_declaration(),
1990 environment: "jvm",
1991 coverage_model: jvm_coverage_model(),
1992 manifest: &self.project.manifest,
1993 probes: &self.project.probes,
1994 widths: &self.project.decision_widths,
1995 integrity: &self.integrity,
1996 source_inputs: &self.source_inputs,
1997 source_roots: self.source_roots.clone(),
1998 directory: &self.directory,
1999 source_files: self.project.instrumented.len(),
2000 },
2001 metadata,
2002 served,
2003 diagnostics,
2004 )
2005 }
2006}
2007
2008#[cfg(test)]
2009mod tests {
2010 use super::*;
2011
2012 #[test]
2013 fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
2014 assert!(asks_for_parallel_execution(Some(
2018 "junit.jupiter.execution.parallel.enabled=true\n"
2019 )));
2020 assert!(asks_for_parallel_execution(Some(
2021 " junit.jupiter.execution.parallel.enabled = True \n"
2022 )));
2023 assert!(!asks_for_parallel_execution(Some(
2024 "junit.jupiter.execution.parallel.enabled=false\n"
2025 )));
2026 assert!(!asks_for_parallel_execution(Some(
2029 "junit.jupiter.execution.parallel.mode.default=concurrent\n"
2030 )));
2031 assert!(!asks_for_parallel_execution(None));
2032 assert!(!asks_for_parallel_execution(Some("")));
2033 }
2034
2035 #[test]
2036 fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
2037 let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
2041 junit.jupiter.execution.parallel.enabled=true\n\
2042 junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
2043 let updated = sequential_properties(Some(existing));
2044 assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
2045 assert!(
2046 !updated.contains("parallel.enabled=true"),
2047 "the project's own setting must not survive:\n{updated}"
2048 );
2049 assert!(updated.contains("testinstance.lifecycle.default=per_class"));
2051 assert!(updated.contains("displayname.generator.default"));
2052
2053 assert!(
2055 sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
2056 );
2057 }
2058
2059 #[test]
2060 fn gradle_is_told_to_run_the_tests_again() {
2061 let (command, note) = command_with_fresh_results(
2064 JvmBuild::Gradle,
2065 &["./gradlew".to_owned(), "test".to_owned()],
2066 );
2067 assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
2068 assert!(note.is_some());
2069
2070 let (command, note) = command_with_fresh_results(
2072 JvmBuild::Gradle,
2073 &[
2074 "./gradlew".to_owned(),
2075 "test".to_owned(),
2076 "--rerun".to_owned(),
2077 ],
2078 );
2079 assert_eq!(command, ["./gradlew", "test", "--rerun"]);
2080 assert!(note.is_none());
2081
2082 let (command, note) =
2084 command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
2085 assert_eq!(command, ["mvn", "test"]);
2086 assert!(note.is_none());
2087 }
2088
2089 #[test]
2090 fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
2091 let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
2094 assert!(
2095 configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
2096 "{configuration}"
2097 );
2098 assert!(configuration.contains("PROBES = 7"));
2099 assert!(configuration.contains("new int[] {2, 3}"));
2100 }
2101
2102 #[test]
2103 fn a_tests_class_is_read_from_the_name_the_framework_chose() {
2104 assert_eq!(
2107 class_of("CalculatorTest#zeroIsNamed()"),
2108 Some("CalculatorTest")
2109 );
2110 assert_eq!(
2111 class_of("CalculatorSpec#a sum adds its parts"),
2112 Some("CalculatorSpec")
2113 );
2114 assert_eq!(class_of("Standalone"), Some("Standalone"));
2115 assert_eq!(class_of(""), None);
2116 }
2117
2118 #[test]
2119 fn the_launcher_version_follows_whatever_junit_the_project_chose() {
2120 assert_eq!(
2125 launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
2126 .as_deref(),
2127 Some("1.10.2")
2128 );
2129 assert_eq!(
2130 launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
2131 .as_deref(),
2132 Some("1.13.1")
2133 );
2134 assert_eq!(
2137 launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
2138 None
2139 );
2140 assert_eq!(
2143 launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
2144 Some(DEFAULT_LAUNCHER_VERSION)
2145 );
2146 assert_eq!(
2152 launcher_version("<artifactId>parent</artifactId>").as_deref(),
2153 Some(DEFAULT_LAUNCHER_VERSION)
2154 );
2155 assert_eq!(
2156 launcher_version("").as_deref(),
2157 Some(DEFAULT_LAUNCHER_VERSION)
2158 );
2159 }
2160
2161 #[test]
2162 fn junit_is_versioned_through_parents_catalogs_and_junit_6() {
2163 assert_eq!(
2165 launcher_version("<artifactId>junit-jupiter</artifactId><version>6.0.1</version>")
2166 .as_deref(),
2167 Some("6.0.1")
2168 );
2169 assert_eq!(engine_version_of_platform("6.0.1"), "6.0.1");
2170 assert_eq!(
2173 launcher_version("<artifactId>junit-jupiter</artifactId><version>${junit}</version><plugin><version>5.2.1</version></plugin>")
2174 .as_deref(),
2175 Some(DEFAULT_LAUNCHER_VERSION)
2176 );
2177 let catalog = "[versions]\njunit = \"5.11.3\"\n\n[libraries]\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\n";
2179 let updated = gradle_with_launcher("plugins { id 'java' }\n", false, catalog, &[]).unwrap();
2180 assert!(
2181 updated.contains("junit-platform-launcher:1.11.3"),
2182 "{updated}"
2183 );
2184
2185 let root = std::env::temp_dir().join(format!(
2187 "supercov-junit-parents-{}-{}",
2188 std::process::id(),
2189 std::time::SystemTime::now()
2190 .duration_since(std::time::UNIX_EPOCH)
2191 .unwrap()
2192 .as_nanos()
2193 ));
2194 let module = root.join("module");
2195 fs::create_dir_all(&module).unwrap();
2196 let child = "<project><parent><groupId>g</groupId><artifactId>p</artifactId><version>1</version></parent><dependencies><dependency><artifactId>junit-jupiter-api</artifactId></dependency></dependencies></project>";
2197 fs::write(
2198 root.join("pom.xml"),
2199 "<project><artifactId>p</artifactId><dependencyManagement><dependencies><dependency><artifactId>junit-bom</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2200 )
2201 .unwrap();
2202 assert_eq!(maven_launcher_version(&module, child), None);
2203 fs::write(
2204 root.join("pom.xml"),
2205 "<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>",
2206 )
2207 .unwrap();
2208 assert_eq!(
2209 maven_launcher_version(&module, child).as_deref(),
2210 Some("1.14.4")
2211 );
2212 fs::remove_file(root.join("pom.xml")).unwrap();
2214 let unknown = child.replace(
2215 "<artifactId>p</artifactId>",
2216 "<artifactId>not-downloaded</artifactId>",
2217 );
2218 assert_eq!(maven_launcher_version(&module, &unknown), None);
2219 fs::remove_dir_all(&root).ok();
2220 }
2221
2222 #[test]
2223 fn the_launcher_is_added_once_and_only_where_it_is_missing() {
2224 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";
2225 let updated =
2226 maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2227 assert!(updated.contains("junit-platform-launcher"), "{updated}");
2228 assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
2229 assert!(
2231 updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
2232 "{updated}"
2233 );
2234 assert_eq!(
2236 maven_with_launcher(&updated, launcher_version(&updated)),
2237 None
2238 );
2239
2240 let bare = "<project>\n <artifactId>demo</artifactId>\n</project>\n";
2242 let updated =
2243 maven_with_launcher(bare, launcher_version(bare)).expect("a block is created");
2244 assert!(updated.contains("<dependencies>"), "{updated}");
2245 assert!(
2246 updated.find("</dependencies>") < updated.find("</project>"),
2247 "{updated}"
2248 );
2249 }
2250
2251 #[test]
2252 fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
2253 let groovy =
2254 "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
2255 let updated =
2256 gradle_with_launcher(groovy, false, "", &[]).expect("the launcher is missing");
2257 assert!(
2258 updated
2259 .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
2260 "{updated}"
2261 );
2262 assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
2264 assert_eq!(gradle_with_launcher(&updated, false, "", &[]), None);
2265
2266 let runtime_only = "dependencies {\n testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
2272 let updated = gradle_with_launcher(runtime_only, false, "", &[])
2273 .expect("a runtime-only declaration does not reach the compiler");
2274 assert!(
2275 updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
2276 "{updated}"
2277 );
2278 assert!(
2279 updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
2280 "the project's own declaration stays:\n{updated}"
2281 );
2282
2283 let kotlin = "dependencies {\n testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
2284 let updated = gradle_with_launcher(kotlin, true, "", &[]).expect("the launcher is missing");
2285 assert!(
2286 updated.contains(
2287 "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
2288 ),
2289 "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
2290 );
2291 assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
2292 }
2293
2294 #[test]
2297 fn a_test_class_named_in_full_finds_its_file() {
2298 let declared_in = BTreeMap::from([
2299 (
2300 "AnnotationsTest".to_owned(),
2301 "src/test/java/com/fasterxml/classmate/AnnotationsTest.java".to_owned(),
2302 ),
2303 (
2304 "CalculatorSpec".to_owned(),
2305 "src/test/kotlin/app/CalculatorSpec.kt".to_owned(),
2306 ),
2307 ]);
2308 assert_eq!(
2309 declaring_file(&declared_in, "com.fasterxml.classmate.AnnotationsTest").as_deref(),
2310 Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2311 );
2312 assert_eq!(
2313 declaring_file(
2314 &declared_in,
2315 "com.fasterxml.classmate.AnnotationsTest$Nested"
2316 )
2317 .as_deref(),
2318 Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2319 );
2320 assert_eq!(
2321 declaring_file(&declared_in, "app.CalculatorSpec").as_deref(),
2322 Some("src/test/kotlin/app/CalculatorSpec.kt")
2323 );
2324 assert_eq!(
2326 declaring_file(&declared_in, "CalculatorSpec").as_deref(),
2327 Some("src/test/kotlin/app/CalculatorSpec.kt")
2328 );
2329 assert_eq!(declaring_file(&declared_in, "org.other.Missing"), None);
2330 }
2331
2332 #[test]
2333 fn a_license_header_check_is_found_through_the_parent_pom() {
2334 let root = std::env::temp_dir().join(format!(
2335 "supercov-rat-{}-{}",
2336 std::process::id(),
2337 std::time::SystemTime::now()
2338 .duration_since(std::time::UNIX_EPOCH)
2339 .unwrap()
2340 .as_nanos()
2341 ));
2342 fs::create_dir_all(root.join("parent")).unwrap();
2343 fs::write(
2344 root.join("parent/pom.xml"),
2345 "<project><artifactId>parent</artifactId><build><plugins><plugin><groupId>org.apache.rat</groupId><artifactId>apache-rat-plugin</artifactId></plugin></plugins></build></project>",
2346 )
2347 .unwrap();
2348 fs::write(
2349 root.join("pom.xml"),
2350 "<project><parent><groupId>g</groupId><artifactId>parent</artifactId><version>1</version><relativePath>parent/pom.xml</relativePath></parent></project>",
2351 )
2352 .unwrap();
2353 assert!(checks_license_headers(&root));
2354 fs::write(
2355 root.join("pom.xml"),
2356 "<project><artifactId>plain</artifactId></project>",
2357 )
2358 .unwrap();
2359 assert!(!checks_license_headers(&root));
2360 fs::remove_dir_all(&root).ok();
2361 }
2362
2363 #[test]
2364 fn only_the_modules_a_command_names_are_expected_to_write_evidence() {
2365 let words = |c: &str| c.split(' ').map(str::to_owned).collect::<Vec<_>>();
2366 assert_eq!(
2367 named_modules(
2368 JvmBuild::Gradle,
2369 &words("./gradlew :moshi:test --tests *Reader*")
2370 ),
2371 Some(BTreeSet::from(["moshi".to_owned()]))
2372 );
2373 assert_eq!(
2374 named_modules(
2375 JvmBuild::Gradle,
2376 &words("gradle :moshi:records-tests:test :test")
2377 ),
2378 Some(BTreeSet::from([
2379 "moshi/records-tests".to_owned(),
2380 ".".to_owned()
2381 ]))
2382 );
2383 assert_eq!(named_modules(JvmBuild::Gradle, &words("gradle test")), None);
2384 assert_eq!(
2385 named_modules(JvmBuild::Gradle, &words("gradle test -x :slow:test")),
2386 None
2387 );
2388 assert_eq!(
2389 named_modules(JvmBuild::Maven, &words("mvn -pl core,api/ test")),
2390 Some(BTreeSet::from(["core".to_owned(), "api".to_owned()]))
2391 );
2392 assert_eq!(
2393 named_modules(JvmBuild::Maven, &words("mvn -pl :core test")),
2394 None
2395 );
2396 assert_eq!(named_modules(JvmBuild::Maven, &words("mvn test")), None);
2397 }
2398
2399 #[test]
2400 fn gradle_moves_junit_4_projects_onto_the_platform_through_vintage() {
2401 let kotlin = "dependencies {\n testImplementation(\"junit:junit:4.13.2\")\n}\n";
2402 let updated = gradle_with_launcher(kotlin, true, "", &[".".into(), "moshi".into()])
2403 .expect("a JUnit 4 project needs Vintage");
2404 assert!(
2405 updated
2406 .contains("\"testRuntimeOnly\"(\"org.junit.vintage:junit-vintage-engine:5.10.2\")"),
2407 "{updated}"
2408 );
2409 assert!(updated.contains("in listOf(\"\", \"moshi\")"), "{updated}");
2410 assert!(
2411 updated.contains("if (options is JUnitOptions) useJUnitPlatform()"),
2412 "only a task still on Gradle's JUnit 4 framework is switched:\n{updated}"
2413 );
2414
2415 let groovy = "dependencies {\n testImplementation 'junit:junit:4.13.2'\n testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'\n}\n";
2417 let updated = gradle_with_launcher(groovy, false, "", &["core".into()])
2418 .expect("a JUnit 4 project needs Vintage");
2419 assert!(
2420 updated.contains("testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2'"),
2421 "{updated}"
2422 );
2423 assert!(updated.contains("in [\"core\"]"), "{updated}");
2424 assert!(
2425 updated.contains("tasks.withType(Test).configureEach"),
2426 "{updated}"
2427 );
2428 assert!(
2429 !updated.contains("\n testImplementation 'org.junit.platform"),
2430 "the launcher is already there:\n{updated}"
2431 );
2432 }
2433
2434 #[test]
2435 fn only_the_listeners_a_project_can_compile_are_written() {
2436 let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
2440 assert!(junit.platform && !junit.testng);
2441
2442 let testng = frameworks("<artifactId>testng</artifactId>");
2443 assert!(testng.testng && !testng.platform);
2444
2445 let both = frameworks("testng ... junit-jupiter");
2448 assert!(both.platform && both.testng);
2449
2450 assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
2453 assert!(frameworks("org.spockframework:spock-core").platform);
2454
2455 let unknown = frameworks("<artifactId>demo</artifactId>");
2458 assert!(unknown.platform && !unknown.testng);
2459 }
2460
2461 #[test]
2462 fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
2463 let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
2469 assert!(four.junit4 && !four.platform && !four.testng);
2470
2471 let five = frameworks("<artifactId>junit-jupiter</artifactId>");
2472 assert!(five.platform && !five.junit4);
2473
2474 let both = frameworks(
2477 "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
2478 );
2479 assert!(both.platform && !both.junit4);
2480
2481 assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
2483 assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
2484 assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
2485 }
2486
2487 #[test]
2488 fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
2489 let root = std::env::temp_dir().join(format!(
2496 "supercov-jpms-{}-{}",
2497 std::process::id(),
2498 std::time::SystemTime::now()
2499 .duration_since(std::time::UNIX_EPOCH)
2500 .unwrap()
2501 .as_nanos()
2502 ));
2503 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();
2504 for module in ["lib", "boundaries"] {
2505 write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
2506 write(
2507 &root.join(module).join("src/main/java/app/Api.java"),
2508 "package app;\npublic class Api { public int one() { return 1; } }\n",
2509 )
2510 .unwrap();
2511 write(
2512 &root.join(module).join("src/test/java/app/ApiTest.java"),
2513 "package app;\nclass ApiTest { void t() {} }\n",
2514 )
2515 .unwrap();
2516 }
2517 write(
2518 &root.join("boundaries/src/test/java/module-info.java"),
2519 "module app.boundaries {\n requires app.lib;\n}\n",
2520 )
2521 .unwrap();
2522
2523 let evidence = root.join("evidence");
2524 let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
2525 assert_eq!(instrumented.modular, ["boundaries"]);
2526
2527 assert!(
2529 root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
2530 .exists()
2531 );
2532 for name in ["SupercovListener.java", "SupercovConfig.java"] {
2533 assert!(
2534 !root
2535 .join("boundaries/src/test/java/com/supercorp/supercov")
2536 .join(name)
2537 .exists(),
2538 "{name} must not be written into a named module"
2539 );
2540 }
2541 assert!(
2542 !root
2543 .join("boundaries/src/test/resources")
2544 .join(SERVICES_FILE)
2545 .exists(),
2546 "and nothing registers a listener that is not there"
2547 );
2548 assert!(
2552 root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
2553 .exists()
2554 );
2555
2556 fs::remove_dir_all(root).ok();
2557 }
2558
2559 #[test]
2560 fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
2561 let root = std::env::temp_dir().join(format!(
2566 "supercov-catalog-{}-{}",
2567 std::process::id(),
2568 std::time::SystemTime::now()
2569 .duration_since(std::time::UNIX_EPOCH)
2570 .unwrap()
2571 .as_nanos()
2572 ));
2573 write(
2574 &root.join("gradle/libs.versions.toml"),
2575 "[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",
2576 )
2577 .unwrap();
2578 let catalog = version_catalog(&root);
2579
2580 let junit4 = frameworks(&with_catalog(
2581 "dependencies { testImplementation(libs.junit) }",
2582 &catalog,
2583 ));
2584 assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
2585
2586 let reflect = frameworks(&with_catalog(
2588 "dependencies { testImplementation(libs.kotlin.reflect) }",
2589 &catalog,
2590 ));
2591 assert!(!reflect.junit4, "{reflect:?}");
2592
2593 let jupiter = frameworks(&with_catalog(
2594 "dependencies { testImplementation(libs.jupiter) }",
2595 &catalog,
2596 ));
2597 assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
2598
2599 let bundle = frameworks(&with_catalog(
2601 "dependencies { testImplementation(libs.bundles.unit) }",
2602 &catalog,
2603 ));
2604 assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
2605
2606 let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
2608 assert_eq!(with_catalog(plain, &catalog), plain);
2609
2610 fs::remove_dir_all(root).ok();
2611 }
2612
2613 #[test]
2614 fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
2615 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";
2621 let updated =
2622 maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2623 let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
2624 let managed_end = updated
2625 .find("</dependencyManagement>")
2626 .expect("managed block");
2627 let build_start = updated.find("<build>").expect("build block");
2628 assert!(
2629 at > managed_end,
2630 "not among the managed versions:\n{updated}"
2631 );
2632 assert!(at < build_start, "nor among a plugin's own:\n{updated}");
2633 assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
2636 }
2637
2638 #[test]
2639 fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
2640 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";
2646 let updated = without_warnings_as_errors(pom).expect("a policy to relax");
2647 assert!(
2648 updated.contains("<failOnWarning>false</failOnWarning>"),
2649 "{updated}"
2650 );
2651 assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
2652 assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
2655
2656 assert_eq!(without_warnings_as_errors("<project></project>"), None);
2658
2659 let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
2662 let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
2663 assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
2664
2665 let only_analyser =
2666 "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
2667 let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
2668 assert!(!updated.contains("ErrorProne"), "{updated}");
2669 }
2670
2671 #[test]
2672 fn vintage_carries_the_engine_version_not_the_platform_one() {
2673 assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
2677 assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
2678
2679 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";
2680 let updated =
2681 maven_with_vintage(pom, launcher_version(pom)).expect("a JUnit 4 project needs both");
2682 assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
2683 assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
2684 assert!(
2685 updated.contains("<groupId>org.junit.vintage</groupId>"),
2686 "{updated}"
2687 );
2688 assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
2691 assert!(
2692 updated.contains(&format!(
2693 "<version>{}</version>",
2694 engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
2695 )),
2696 "{updated}"
2697 );
2698
2699 assert_eq!(
2701 maven_with_vintage(&updated, launcher_version(&updated)),
2702 None
2703 );
2704 }
2705}