Skip to main content

supercov_engine/
jvm_run.rs

1//! Public, isolated JVM coverage run lifecycle, for Java and Kotlin.
2//!
3//! Supercov owns the probes, so measuring a project means rewriting its
4//! sources. That happens on a copy in an isolated workspace; the tree the
5//! author edits is never touched.
6//!
7//! The build system runs the tests, and Supercov puts three things where that
8//! build will find them without being reconfigured:
9//!
10//! - `Supercov` in the main source set, because instrumented product code
11//!   stores into its probe array;
12//! - `SupercovListener` and a generated `SupercovConfig` in the test source
13//!   set, because attribution comes from the JUnit Platform and only the test
14//!   classpath has it;
15//! - a services file registering the listener, and a
16//!   `junit-platform.properties` that turns parallel execution off.
17//!
18//! That last one is prevention rather than detection. Two tests running at
19//! once share one probe array, so the runtime cannot attribute either, and it
20//! notices and says so — but the better outcome is that it never happens, and
21//! Supercov owns the workspace, so it can simply make sure of it.
22
23use std::{
24    collections::{BTreeMap, BTreeSet},
25    ffi::OsString,
26    fs,
27    io::Write,
28    path::{Path, PathBuf},
29    time::Instant,
30};
31
32use serde::{Deserialize, Serialize};
33
34use crate::{
35    evidence_archive::write_archive,
36    frontend_protocol::validate_frontend_report_request,
37    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
38    jvm_project::{
39        JvmBuild, PreparedJvmProject, detect_build, jvm_integrity_inputs, prepare_jvm_project,
40    },
41    lifecycle::{
42        ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
43        remove_stored_tree_deferred,
44    },
45    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
46    owned_evidence::{
47        OwnedRunInputs, OwnedTestOutcome, build_frontend_run, jvm_coverage_model, jvm_declaration,
48        merge_evidence, read_evidence,
49    },
50    process_supervision::{CommandSpec, SupervisionOptions},
51    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
52    workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
53};
54
55const RUNTIME_SOURCE: &str =
56    include_str!("../runtime-assets/jvm/com/supercorp/supercov/Supercov.java");
57const LISTENER_SOURCE: &str =
58    include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovListener.java");
59
60const TESTNG_LISTENER_SOURCE: &str =
61    include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovTestNGListener.java");
62
63const PACKAGE_DIRECTORY: &str = "com/supercorp/supercov";
64
65/// Where the JUnit Platform looks for listeners to register.
66const SERVICES_FILE: &str = "META-INF/services/org.junit.platform.launcher.TestExecutionListener";
67
68const LISTENER_CLASS: &str = "com.supercorp.supercov.SupercovListener";
69
70/// Where TestNG looks for listeners to register.
71const TESTNG_SERVICES_FILE: &str = "META-INF/services/org.testng.ITestNGListener";
72
73const TESTNG_LISTENER_CLASS: &str = "com.supercorp.supercov.SupercovTestNGListener";
74
75/// Which test frameworks a project actually depends on.
76///
77/// This decides which listeners are written, and it has to: each is compiled
78/// from the project's own test sources, so one whose framework is absent would
79/// fail on imports the project never asked for. A project that names neither
80/// gets the platform listener, which is what nearly every JVM suite runs on
81/// and what Kotest and Spock report through.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83struct Frameworks {
84    platform: bool,
85    testng: bool,
86    /// JUnit 4 with no platform engine beside it. Supercov cannot attribute
87    /// such a suite, and must not try: see `frameworks`.
88    junit4: bool,
89}
90
91/// A Gradle version catalog, read as the accessors a build file writes.
92///
93/// `testImplementation(libs.junit)` says nothing about which framework that is.
94/// `gradle/libs.versions.toml` says `junit = "junit:junit:4.13.2"`. A catalog
95/// is how Gradle builds are written now, and to a reader that does not open one
96/// every dependency declared through it is invisible: moshi's suite is JUnit 4,
97/// was taken for a platform one because nothing said otherwise, and recorded
98/// nothing at all while its 25 test classes passed.
99///
100/// An alias is addressed with dots where it is declared with dashes, so
101/// `kotlin-reflect` is written `libs.kotlin.reflect`. Coordinates come back
102/// quoted, the shape they would have had written inline, because that is what
103/// `frameworks` reads.
104fn 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    // Naming a bundle depends on every library in it. A bundle is addressed
135    // under `libs.bundles.`, a library directly under `libs.`.
136    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
154/// The build text with the coordinates of every catalog accessor it names.
155fn with_catalog(text: &str, catalog: &BTreeMap<String, String>) -> String {
156    let mut out = text.to_owned();
157    for (accessor, coordinates) in catalog {
158        // `libs.kotlin` must not answer for `libs.kotlin.reflect`.
159        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    // The platform is what Jupiter, Vintage, Kotest and Spock all run on.
177    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    // JUnit 4 is not a platform engine and does not run on one. Surefire runs
187    // it through a provider of its own, and choosing that provider is decided
188    // by what is on the classpath -- so adding the launcher to a JUnit 4
189    // project makes surefire switch to the platform provider, find no engine
190    // there, and fail the suite outright.
191    let junit4 = !platform
192        && (build_file.contains("<groupId>junit</groupId>") || build_file.contains("'junit:junit"))
193        || build_file.contains("\"junit:junit");
194    Frameworks {
195        // A TestNG-only or JUnit-4-only project would fail on a platform
196        // listener, so the fallback applies only when nothing was recognised.
197        platform: platform || !(testng || junit4),
198        testng,
199        junit4: junit4 && !platform,
200    }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct DirectJvmRunRequest {
206    pub root: PathBuf,
207    pub command: Vec<String>,
208    pub run_id: String,
209    pub started_at: String,
210    /// Credit every test with what it reached, by running the suite in order.
211    ///
212    /// Attribution is a sweep of one shared probe array at each test boundary,
213    /// so two tests running at once cannot both be credited. Turning JUnit's
214    /// parallel execution off is what buys that, and what it costs is the
215    /// suite's own parallelism -- 3.6x on sixteen I/O-bound tests across
216    /// fifteen cores. Supercov used to spend that without being asked.
217    #[serde(default)]
218    pub exact_attribution: bool,
219}
220
221#[derive(Debug, Clone, PartialEq)]
222pub struct DirectJvmRunResult {
223    pub run_id: String,
224    pub run_directory: PathBuf,
225    pub exit_code: i32,
226    pub tests: usize,
227    pub source_files: usize,
228    pub modules: usize,
229    pub build: JvmBuild,
230    pub recovered_runs: Vec<String>,
231    pub metadata: RunMetadata,
232}
233
234fn elapsed_ms(started: Instant) -> f64 {
235    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
236}
237
238fn write(path: &Path, contents: &str) -> Result<(), String> {
239    if let Some(parent) = path.parent() {
240        fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
241    }
242    fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
243}
244
245/// The source root Supercov's own Java lands in for a given source set.
246///
247/// Maven, Gradle and a plain tree have meant the same thing by these paths for
248/// long enough that the convention is more reliable than reading a build file,
249/// and a Kotlin project compiles Java from here too.
250fn source_root(source_set: &str) -> String {
251    format!("src/{source_set}/java")
252}
253
254/// Java source escaping for a string literal, so a Windows path or a name with
255/// a quote in it cannot end the literal early.
256fn java_literal(value: &str) -> String {
257    let mut out = String::with_capacity(value.len() + 2);
258    out.push('"');
259    for character in value.chars() {
260        match character {
261            '"' => out.push_str("\\\""),
262            '\\' => out.push_str("\\\\"),
263            '\n' => out.push_str("\\n"),
264            '\r' => out.push_str("\\r"),
265            other => out.push(other),
266        }
267    }
268    out.push('"');
269    out
270}
271
272/// The runtime, sized for this project.
273///
274/// A probe is a bare store into the array with nothing checking its bounds,
275/// which is what makes it cost one instruction. So the array has to be the
276/// right size before any product code runs, rather than only once a listener
277/// arms it: a run where no listener starts -- a suite in a language Supercov
278/// does not parse, a build that never reaches the test task -- would otherwise
279/// throw on the first instrumented line, and Supercov would have turned a
280/// passing suite into a failing one.
281fn runtime_source(probe_count: usize) -> String {
282    let marker = "static final int PROBE_COUNT = 0; // supercov:probe-count";
283    debug_assert!(
284        RUNTIME_SOURCE.contains(marker),
285        "the runtime no longer declares the probe count Supercov substitutes"
286    );
287    RUNTIME_SOURCE.replace(
288        marker,
289        &format!("static final int PROBE_COUNT = {probe_count}; // supercov:probe-count"),
290    )
291}
292
293/// What the listener reads to know the shape of the run it is recording.
294fn configuration(probe_count: usize, widths: &[u8], evidence: &Path) -> String {
295    let widths = widths
296        .iter()
297        .map(u8::to_string)
298        .collect::<Vec<_>>()
299        .join(", ");
300    format!(
301        "// Code generated by Supercov. DO NOT EDIT.\npackage com.supercorp.supercov;\n\npublic final class SupercovConfig {{\n  private SupercovConfig() {{}}\n\n  public static final int PROBES = {probe_count};\n  public static final int[] WIDTHS = new int[] {{{widths}}};\n  public static final String EVIDENCE = {};\n}}\n",
302        java_literal(&evidence.to_string_lossy())
303    )
304}
305
306/// Whether the project's own configuration asked for parallel execution.
307///
308/// Only worth telling someone about when it was on: a project that never
309/// enabled it is not having anything taken away, and a line about every run
310/// is a line nobody reads.
311fn asks_for_parallel_execution(existing: Option<&str>) -> bool {
312    existing.unwrap_or("").lines().any(|line| {
313        let line = line.trim_start();
314        line.starts_with("junit.jupiter.execution.parallel.enabled")
315            && line
316                .split_once('=')
317                .is_some_and(|(_, value)| value.trim().eq_ignore_ascii_case("true"))
318    })
319}
320
321/// The project's JUnit Platform configuration with parallel execution turned
322/// off, keeping whatever else it already said.
323///
324/// Attribution is a sweep of one shared probe array at each test boundary, so
325/// two tests running at once cannot both be credited. The runtime notices and
326/// degrades honestly; this is the half that means it never has to.
327fn sequential_properties(existing: Option<&str>) -> String {
328    let mut out = String::new();
329    for line in existing.unwrap_or("").lines() {
330        if line
331            .trim_start()
332            .starts_with("junit.jupiter.execution.parallel.enabled")
333        {
334            continue;
335        }
336        out.push_str(line);
337        out.push('\n');
338    }
339    out.push_str(
340        "# Set by Supercov: probes are a store into one shared array, so two tests running\n\
341         # at once cannot both be credited with what they reached.\n\
342         junit.jupiter.execution.parallel.enabled=false\n",
343    );
344    out
345}
346
347/// The JUnit Platform artifact the listener is written against.
348///
349/// `junit-jupiter` does not bring it: Maven's surefire and Gradle's test task
350/// both put it on the test *runtime* classpath because they need it
351/// themselves, but neither offers it at compile time, so a listener declared
352/// in the project's own test sources will not compile without it. Supercov
353/// owns the workspace copy, so it adds the dependency there — the same line a
354/// person would add, in a tree the author never sees.
355const LAUNCHER_ARTIFACT: &str = "junit-platform-launcher";
356
357/// The launcher version to ask for.
358///
359/// JUnit numbers the platform 1.N alongside Jupiter 5.N, so a project that
360/// pins its Jupiter version tells us exactly which launcher agrees with the
361/// engine it will run on. A project using the BOM has already said how to
362/// version every JUnit artifact, and naming a version there would override the
363/// answer it gave.
364fn launcher_version(build_file: &str) -> Option<String> {
365    // The one case where naming no version is right: a project using the BOM
366    // has already said how every JUnit artifact is versioned, and naming one
367    // would override the answer it gave. Every other path must produce a
368    // version -- a versionless dependency in a pom with no BOM to manage it is
369    // one Maven cannot resolve, and the build stops before any test runs.
370    if build_file.contains("junit-bom") {
371        return None;
372    }
373    Some(jupiter_version(build_file).unwrap_or_else(|| DEFAULT_LAUNCHER_VERSION.to_owned()))
374}
375
376/// The platform version matching whatever Jupiter this file pins, if it pins
377/// one. JUnit numbers the platform 1.N alongside Jupiter 5.N.
378fn jupiter_version(build_file: &str) -> Option<String> {
379    let jupiter = build_file.find("junit-jupiter")?;
380    let rest = &build_file[jupiter..];
381    rest.match_indices("5.").find_map(|(at, _)| {
382        let tail = &rest[at + 2..];
383        let minor = tail
384            .chars()
385            .take_while(char::is_ascii_digit)
386            .collect::<String>();
387        let patch = tail[minor.len()..]
388            .strip_prefix('.')?
389            .chars()
390            .take_while(char::is_ascii_digit)
391            .collect::<String>();
392        (!minor.is_empty() && !patch.is_empty()).then(|| format!("1.{minor}.{patch}"))
393    })
394}
395
396const DEFAULT_LAUNCHER_VERSION: &str = "1.10.2";
397
398/// The engine version matching a platform version: 1.N.P becomes 5.N.P.
399fn engine_version_of_platform(platform: &str) -> String {
400    match platform.strip_prefix("1.") {
401        Some(rest) => format!("5.{rest}"),
402        None => platform.to_owned(),
403    }
404}
405
406/// `pom.xml` with the launcher among its test dependencies.
407fn maven_with_launcher(pom: &str) -> Option<String> {
408    maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT])
409}
410
411/// The engine that runs JUnit 4 tests on the JUnit Platform.
412///
413/// A JUnit 4 suite is not a platform one and cannot be attributed as it
414/// stands. Vintage is the platform's own answer: it discovers and runs exactly
415/// the same JUnit 4 tests, through the lifecycle Supercov listens to. Adding
416/// it to the copy turns a suite Supercov could only decline into one it can
417/// measure, and the author's own build still runs JUnit 4 as before.
418const VINTAGE_ARTIFACT: &str = "junit-vintage-engine";
419
420fn maven_with_vintage(pom: &str) -> Option<String> {
421    maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT, VINTAGE_ARTIFACT])
422}
423
424fn maven_with_test_artifacts(pom: &str, artifacts: &[&str]) -> Option<String> {
425    let missing = artifacts
426        .iter()
427        .filter(|artifact| !pom.contains(**artifact))
428        .collect::<Vec<_>>();
429    if missing.is_empty() {
430        return None;
431    }
432    let platform = launcher_version(pom);
433    let dependency = missing
434        .iter()
435        .map(|artifact| {
436            // Vintage is versioned with Jupiter, not with the platform: JUnit
437            // numbers the engines 5.N and the platform 1.N, and asking for
438            // vintage 1.N asks for something that was never published.
439            let (group, version) = if **artifact == VINTAGE_ARTIFACT {
440                (
441                    "org.junit.vintage",
442                    platform.as_deref().map(engine_version_of_platform),
443                )
444            } else {
445                ("org.junit.platform", platform.clone())
446            };
447            let version = version
448                .map(|version| format!("\n      <version>{version}</version>"))
449                .unwrap_or_default();
450            format!(
451                "    <dependency>\n      <groupId>{group}</groupId>\n      <artifactId>{artifact}</artifactId>{version}\n      <scope>test</scope>\n    </dependency>\n"
452            )
453        })
454        .collect::<String>();
455    match project_dependencies_end(pom) {
456        Some(at) => Some(format!("{}{dependency}{}", &pom[..at], &pom[at..])),
457        // A project with no dependencies block of its own still needs one.
458        None => pom.rfind("</project>").map(|at| {
459            format!(
460                "{}  <dependencies>\n{dependency}  </dependencies>\n{}",
461                &pom[..at],
462                &pom[at..]
463            )
464        }),
465    }
466}
467
468/// Where the project's own `<dependencies>` ends.
469///
470/// Not simply the last one. A pom's `<dependencyManagement>` holds a
471/// `<dependencies>` too, and so does every `<profile>` and every `<plugin>`;
472/// a dependency added inside `<dependencyManagement>` is a version for
473/// something else to ask for rather than something the project depends on, so
474/// the module compiles exactly as it did before and the listener still cannot
475/// find the API it implements. Depth is what tells them apart.
476fn project_dependencies_end(pom: &str) -> Option<usize> {
477    const NESTED: [&str; 4] = ["dependencyManagement", "profiles", "build", "reporting"];
478    let bytes = pom.as_bytes();
479    let mut depth = 0usize;
480    let mut at = 0usize;
481    while at < bytes.len() {
482        let Some(open) = pom[at..].find('<') else {
483            break;
484        };
485        let start = at + open;
486        let Some(close) = pom[start..].find('>') else {
487            break;
488        };
489        let tag = &pom[start + 1..start + close];
490        at = start + close + 1;
491        let name = tag.trim_start_matches('/').trim_end_matches('/').trim();
492        let name = name.split_whitespace().next().unwrap_or_default();
493        if NESTED.contains(&name) {
494            if tag.starts_with('/') {
495                depth = depth.saturating_sub(1);
496            } else if !tag.ends_with('/') {
497                depth += 1;
498            }
499        } else if name == "dependencies" && tag.starts_with('/') && depth == 0 {
500            return Some(start);
501        }
502    }
503    None
504}
505
506/// Whether a Gradle script already puts the launcher where test *sources* can
507/// see it.
508///
509/// Presence is not enough. Gradle 9 requires every project to declare the
510/// launcher itself, and the configuration its own documentation recommends is
511/// `testRuntimeOnly` — which puts the artifact on the classpath the tests run
512/// with and not the one they compile against. A project following that advice
513/// has the artifact and still cannot compile a listener, so the question is
514/// which configuration declares it, not whether one does.
515fn declares_launcher_for_compilation(build_file: &str) -> bool {
516    build_file.lines().any(|line| {
517        line.contains(LAUNCHER_ARTIFACT)
518            && ["testImplementation", "testCompileOnly", "testApi"]
519                .iter()
520                .any(|configuration| line.contains(configuration))
521    })
522}
523
524/// A Gradle build file with the launcher among its test dependencies.
525///
526/// Appended as its own `dependencies` block rather than edited into the
527/// existing one: Gradle merges them, and finding the right brace in a Groovy
528/// or Kotlin script by hand is the kind of parsing that works until it does
529/// not.
530fn gradle_with_launcher(build_file: &str, kotlin: bool) -> Option<String> {
531    if declares_launcher_for_compilation(build_file) {
532        return None;
533    }
534    let coordinate = match launcher_version(build_file) {
535        Some(version) => format!("org.junit.platform:{LAUNCHER_ARTIFACT}:{version}"),
536        None => format!("org.junit.platform:{LAUNCHER_ARTIFACT}"),
537    };
538    // `allprojects` rather than a bare `dependencies` block, because a
539    // multi-project build compiles each subproject's test sources against that
540    // subproject's own classpath and a declaration in the root reaches none of
541    // them. Guarded by the java plugin so a root that only aggregates — which
542    // has no test source set and no configurations to add to — is left alone.
543    // In the Kotlin DSL the typed accessor does not exist inside `allprojects`,
544    // so the configuration is named as a string.
545    let line = if kotlin {
546        format!("            \"testImplementation\"(\"{coordinate}\")")
547    } else {
548        format!("            testImplementation '{coordinate}'")
549    };
550    let plugin = if kotlin { "\"java\"" } else { "'java'" };
551    Some(format!(
552        "{build_file}\n// Added by Supercov: the JUnit Platform listener that attributes coverage\n// to each test is compiled from each project's own test sources, and the\n// launcher API it implements is on the test runtime classpath but not the\n// compile one.\nallprojects {{\n    plugins.withId({plugin}) {{\n        dependencies {{\n{line}\n        }}\n    }}\n}}\n"
553    ))
554}
555
556/// A build file with its warnings-as-errors policy relaxed.
557///
558/// A project is free to fail its build on any warning, and several good ones
559/// do. The instrumented copy contains code that project never wrote and never
560/// agreed a style for, so its own policy would reject it -- gson's Error Prone
561/// configuration rejects a fully-qualified name, and nothing Supercov can emit
562/// satisfies every such rule. The Rust frontend caps lints for the same reason
563/// and in the same place: the copy, never the tree the author keeps.
564///
565/// Only the escalation is removed. The warnings are still emitted, the
566/// compiler still compiles exactly what it would have, and the author's own
567/// build is untouched.
568fn without_warnings_as_errors(build_file: &str) -> Option<String> {
569    let mut updated = build_file.to_owned();
570    for (from, to) in [
571        (
572            "<failOnWarning>true</failOnWarning>",
573            "<failOnWarning>false</failOnWarning>",
574        ),
575        (
576            "<failOnWarnings>true</failOnWarnings>",
577            "<failOnWarnings>false</failOnWarnings>",
578        ),
579        ("<arg>-Werror</arg>", ""),
580        ("<compilerArgument>-Werror</compilerArgument>", ""),
581        ("options.compilerArgs << '-Werror'", ""),
582        ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
583    ] {
584        updated = updated.replace(from, to);
585    }
586    updated = without_error_prone(&updated);
587    (updated != build_file).then_some(updated)
588}
589
590/// The same build file with Error Prone switched off.
591///
592/// Relaxing warnings is not enough on its own: Error Prone has checks that
593/// fail at error severity, and some are about the shape of a method rather
594/// than its meaning — an `@InlineMe` method must hold exactly one statement,
595/// and a probe makes two. No instrumentation can satisfy a rule like that,
596/// because the rule is about source the author wrote and the copy holds source
597/// they did not.
598///
599/// Switching the analyser off in the copy costs nothing: it says nothing about
600/// whether the tests pass, and the author's own build still runs it in full.
601fn without_error_prone(build_file: &str) -> String {
602    let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
603        return build_file.to_owned();
604    };
605    let Some(end) = build_file[start..].find("</arg>") else {
606        return build_file.to_owned();
607    };
608    let mut updated = build_file.to_owned();
609    updated.replace_range(start..start + end + "</arg>".len(), "");
610    updated
611}
612
613/// Make the build run its tests again rather than reporting a cached result.
614///
615/// A test that does not run records nothing, and a run that measured half a
616/// suite without saying so is worse than one that took longer.
617fn command_with_fresh_results(
618    build: JvmBuild,
619    command: &[String],
620) -> (Vec<String>, Option<String>) {
621    let mut updated = command.to_vec();
622    match build {
623        JvmBuild::Gradle => {
624            if updated
625                .iter()
626                .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
627            {
628                return (updated, None);
629            }
630            updated.push("--rerun-tasks".into());
631            (
632                updated,
633                Some(
634                    "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
635                        .into(),
636                ),
637            )
638        }
639        // Maven's default lifecycle re-runs surefire every time, and Gradle's
640        // build cache has no Maven equivalent worth defeating here.
641        JvmBuild::Maven | JvmBuild::Plain => (updated, None),
642    }
643}
644
645/// One module of the build, and where its evidence lands.
646///
647/// A single-project build has exactly one of these, rooted at the workspace.
648/// A multi-module build has one per module, because each module compiles only
649/// its own source set and forks its own JVM to run its tests: a runtime
650/// written once at the top would be invisible to every module, and one
651/// evidence path shared by every module's JVM would be overwritten by
652/// whichever finished last.
653#[derive(Debug, Clone)]
654struct JvmModule {
655    /// Relative to the workspace, `/`-separated; `.` for the build root.
656    directory: String,
657    /// Where this module's JVMs write. A directory rather than a file: a build
658    /// may fork more than one to run tests in parallel, and each writes under
659    /// a name of its own so none overwrites another.
660    evidence: PathBuf,
661    has_tests: bool,
662}
663
664struct InstrumentedWorkspace {
665    project: PreparedJvmProject,
666    build: JvmBuild,
667    modules: Vec<JvmModule>,
668    /// Which file declared each test class, so a result can point at a source.
669    declared_in: BTreeMap<String, String>,
670    /// The build file the launcher dependency was added to, if it was.
671    added_launcher: Option<&'static str>,
672    /// Modules whose own configuration asked for parallel execution and had it
673    /// turned off in the copy, so the user hears that their suite ran a
674    /// different way rather than discovering it from a test that now hangs.
675    serialised: BTreeSet<String>,
676    /// Modules left running their tests at once, where what overlaps cannot be
677    /// credited to either test that produced it.
678    concurrent: BTreeSet<String>,
679    /// Whether a JUnit 4 module was given the engine that runs it on the
680    /// platform, so the user hears that their suite ran a different way.
681    added_vintage: bool,
682    /// What had to be relaxed in the copy's build files for instrumented code
683    /// to compile, named so the user knows rather than infers.
684    relaxed: Vec<&'static str>,
685    /// Modules Supercov instrumented but cannot attribute, and why they were
686    /// left without a listener rather than broken by one.
687    unmeasurable: Vec<String>,
688    /// Modules whose tests are a named JPMS module, which cannot take a listener.
689    modular: Vec<String>,
690}
691
692/// The module a `src/main/...` or `src/test/...` path belongs to.
693///
694/// The build root for a single-project build, and the subdirectory holding
695/// that source set otherwise. Derived from the paths themselves rather than
696/// from the build file, because Maven's `<modules>` and Gradle's
697/// `settings.gradle` say the same thing in two languages and the layout says
698/// it in one.
699fn module_of(relative: &str) -> String {
700    match relative.find("src/") {
701        Some(0) | None => ".".to_owned(),
702        Some(at) => relative[..at].trim_end_matches('/').to_owned(),
703    }
704}
705
706/// The class a test's reported name belongs to, as JUnit names it:
707/// `CalculatorTest#zeroIsNamed()`, or a DSL framework's own wording.
708fn class_of(test_name: &str) -> Option<&str> {
709    test_name.split('#').next().filter(|name| !name.is_empty())
710}
711
712fn instrument_workspace(
713    workspace: &Path,
714    evidence_directory: &Path,
715    exact_attribution: bool,
716    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
717) -> Result<InstrumentedWorkspace, String> {
718    let project = prepare_jvm_project(workspace, roots)?;
719    let build = detect_build(workspace);
720
721    for (relative, instrumented) in &project.instrumented {
722        write(&workspace.join(relative), instrumented)?;
723    }
724
725    let probe_count = project
726        .probes
727        .keys()
728        .max()
729        .map_or(0, |highest| *highest as usize + 1);
730
731    // Which frameworks a module runs is a question about that module. A
732    // repository can hold a JUnit 4 module beside a JUnit 5 one, and asking
733    // the whole tree at once answers neither: the platform listener would go
734    // into the JUnit 4 module, where it cannot work, and the launcher with it,
735    // where it makes the build choose a provider that finds no engine.
736    let catalog = version_catalog(workspace);
737    let frameworks_of = |directory: &str| -> Frameworks {
738        let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
739        let mut text = names
740            .iter()
741            .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
742            .unwrap_or_default();
743        for name in names {
744            if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
745                text.push('\n');
746                text.push_str(&own);
747                break;
748            }
749        }
750        frameworks(&with_catalog(&text, &catalog))
751    };
752    let mut unmeasurable: Vec<String> = Vec::new();
753    let mut modular: Vec<String> = Vec::new();
754
755    // One entry per module that has a main or a test source set, keyed by
756    // directory so a module contributing both is listed once.
757    let mut modules: BTreeMap<String, bool> = BTreeMap::new();
758    for (relative, _) in &project.files.sources {
759        modules.entry(module_of(relative)).or_insert(false);
760    }
761    for (relative, _) in &project.files.tests {
762        *modules.entry(module_of(relative)).or_default() = true;
763    }
764    // A module's tests may be in a language Supercov does not parse -- Spock
765    // writes them in Groovy, and Supercov measures the Java and Kotlin they
766    // exercise rather than the specification itself. Those files are not in
767    // `files.tests`, so the source sets are asked directly: a module judged to
768    // have no tests gets no listener, and a run with no listener records
769    // nothing at all.
770    for module in modules.keys().cloned().collect::<Vec<_>>() {
771        if workspace.join(&module).join("src/test").is_dir() {
772            modules.insert(module, true);
773        }
774    }
775    if modules.is_empty() {
776        modules.insert(".".to_owned(), true);
777    }
778
779    let modules = modules
780        .into_iter()
781        .map(|(directory, has_tests)| JvmModule {
782            evidence: evidence_directory.join(
783                directory
784                    .chars()
785                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
786                    .collect::<String>(),
787            ),
788            directory,
789            has_tests,
790        })
791        .collect::<Vec<_>>();
792
793    for module in &modules {
794        let frameworks = frameworks_of(&module.directory);
795        let at = |source_set: &str| {
796            workspace
797                .join(&module.directory)
798                .join(source_root(source_set))
799                .join(PACKAGE_DIRECTORY)
800        };
801        // The runtime goes in every module's main source set: instrumented
802        // product code stores into its array, and a module compiles only its
803        // own sources. The class is identical everywhere, and each module's
804        // tests fork a JVM that loads exactly one of them.
805        write(
806            &at("main").join("Supercov.java"),
807            &runtime_source(probe_count),
808        )?;
809        if !module.has_tests {
810            continue;
811        }
812        if frameworks.junit4 && build != JvmBuild::Maven {
813            // Only Maven's copy gets Vintage added below; elsewhere the module
814            // keeps its probes and gets no listener, because attributing it is
815            // impossible and trying would break it.
816            unmeasurable.push(module.directory.clone());
817            continue;
818        }
819        // A test source set with a module-info.java is a named JPMS module,
820        // and a named module is closed: every package it holds is its own and
821        // every dependency has to be declared in that file. A listener written
822        // into it imports org.junit.platform, which the module does not
823        // require, and reads the runtime from a package its main module does
824        // not export -- so the module stops compiling and takes the build with
825        // it. gson's test-jpms is exactly this, and it tests module boundaries
826        // rather than product logic, so leaving it alone costs the report
827        // nothing it could have had.
828        if workspace
829            .join(&module.directory)
830            .join(source_root("test"))
831            .join("module-info.java")
832            .exists()
833        {
834            modular.push(module.directory.clone());
835            continue;
836        }
837        // The listeners and their configuration go in the test source set,
838        // because only the test classpath has the frameworks to listen to.
839        let test = at("test");
840        write(
841            &test.join("SupercovConfig.java"),
842            &configuration(probe_count, &project.decision_widths, &module.evidence),
843        )?;
844        if frameworks.platform || frameworks.junit4 {
845            write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
846        }
847        if frameworks.testng {
848            write(
849                &test.join("SupercovTestNGListener.java"),
850                TESTNG_LISTENER_SOURCE,
851            )?;
852        }
853    }
854
855    // A project's warning policy applies to code it wrote. The copy holds code
856    // it did not.
857    let mut relaxed: Vec<&'static str> = Vec::new();
858    for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
859        let path = workspace.join(name);
860        let Ok(existing) = fs::read_to_string(&path) else {
861            continue;
862        };
863        let Some(updated) = without_warnings_as_errors(&existing) else {
864            continue;
865        };
866        // Named separately, because switching a static analyser off is a
867        // bigger thing than not failing on a warning and the user should hear
868        // it said rather than work it out.
869        if !relaxed.contains(&"stopped the build failing on warnings")
870            && updated.contains("<failOnWarning>false</failOnWarning>")
871                != existing.contains("<failOnWarning>false</failOnWarning>")
872            || existing.contains("-Werror") && !updated.contains("-Werror")
873        {
874            relaxed.push("stopped the build failing on warnings");
875        }
876        if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
877            relaxed.push("switched Error Prone off");
878        }
879        write(&path, &updated)?;
880    }
881    relaxed.dedup();
882
883    // The platform listener is compiled from the project's own test sources,
884    // so the launcher API it implements has to be on the compile classpath. A
885    // TestNG-only project needs none of that: it already depends on the
886    // framework its own listener implements.
887    let mut added_launcher = None;
888    let mut serialised: BTreeSet<String> = BTreeSet::new();
889    let mut concurrent: BTreeSet<String> = BTreeSet::new();
890    let mut added_vintage = false;
891    match build {
892        // Per module, not once at the top: the module's own pom is where its
893        // JUnit version is in scope, and a module that runs JUnit 4 must not
894        // get the launcher at all.
895        JvmBuild::Maven => {
896            for module in modules
897                .iter()
898                .filter(|module| {
899                    module.has_tests
900                        && !unmeasurable.contains(&module.directory)
901                        && !modular.contains(&module.directory)
902                })
903                // A TestNG module needs none of this: it already depends on
904                // the framework its own listener implements, and the launcher
905                // would only change which provider the build chooses.
906                .filter(|module| {
907                    let frameworks = frameworks_of(&module.directory);
908                    frameworks.platform || frameworks.junit4
909                })
910            {
911                let pom = workspace.join(&module.directory).join("pom.xml");
912                let Ok(existing) = fs::read_to_string(&pom) else {
913                    continue;
914                };
915                // A JUnit 4 module also needs the engine that runs JUnit 4
916                // tests on the platform; without it the launcher would find no
917                // engine at all.
918                let updated = if frameworks_of(&module.directory).junit4 {
919                    added_vintage = true;
920                    maven_with_vintage(&existing)
921                } else {
922                    maven_with_launcher(&existing)
923                };
924                if let Some(updated) = updated {
925                    write(&pom, &updated)?;
926                    added_launcher = Some("pom.xml");
927                }
928            }
929        }
930        JvmBuild::Gradle
931            if !modules
932                .iter()
933                .any(|module| module.has_tests && frameworks_of(&module.directory).platform) => {}
934        JvmBuild::Gradle => {
935            for name in ["build.gradle.kts", "build.gradle"] {
936                let path = workspace.join(name);
937                let Ok(existing) = fs::read_to_string(&path) else {
938                    continue;
939                };
940                if let Some(updated) = gradle_with_launcher(&existing, name.ends_with(".kts")) {
941                    write(&path, &updated)?;
942                    added_launcher = Some(if name.ends_with(".kts") {
943                        "build.gradle.kts"
944                    } else {
945                        "build.gradle"
946                    });
947                }
948                break;
949            }
950        }
951        // Nothing resolves dependencies for a plain tree; whoever compiles it
952        // supplies the classpath.
953        JvmBuild::Plain => {}
954    }
955
956    for module in modules.iter().filter(|module| {
957        module.has_tests
958            && !unmeasurable.contains(&module.directory)
959            && !modular.contains(&module.directory)
960    }) {
961        let frameworks = frameworks_of(&module.directory);
962        let resources = workspace.join(&module.directory).join("src/test/resources");
963        if frameworks.platform || frameworks.junit4 {
964            write(
965                &resources.join(SERVICES_FILE),
966                &format!("{LISTENER_CLASS}\n"),
967            )?;
968            let properties = resources.join("junit-platform.properties");
969            let existing = fs::read_to_string(&properties).ok();
970            if !exact_attribution {
971                // The suite runs the way its author wrote it. Where that means
972                // tests run at once the runtime notices, says what it could
973                // not credit, and the run records it -- which is a smaller
974                // price than several times the suite's wall time, and not one
975                // Supercov gets to spend on somebody's behalf.
976                if asks_for_parallel_execution(existing.as_deref()) {
977                    concurrent.insert(module.directory.clone());
978                }
979                continue;
980            }
981            // Said out loud, because it changes how the suite runs rather than
982            // how it is measured. Every other thing Supercov does to the
983            // workspace is announced -- the isolated copy, the added launcher,
984            // `-count=1` over in Go -- and this is the one with a chance of
985            // changing an outcome: a test that waits on another test's thread
986            // passes under the project's own command and hangs under this one.
987            if asks_for_parallel_execution(existing.as_deref()) {
988                serialised.insert(module.directory.clone());
989            }
990            write(&properties, &sequential_properties(existing.as_deref()))?;
991        }
992        if frameworks.testng {
993            write(
994                &resources.join(TESTNG_SERVICES_FILE),
995                &format!("{TESTNG_LISTENER_CLASS}\n"),
996            )?;
997        }
998    }
999
1000    // A test class's file, so a result can name where it came from. Matched on
1001    // the class rather than the test, because the name a framework reports for
1002    // a test is its own and need not be a method at all.
1003    let mut declared_in = BTreeMap::new();
1004    for (relative, _) in &project.files.tests {
1005        if let Some(stem) = relative
1006            .rsplit('/')
1007            .next()
1008            .and_then(|name| name.split('.').next())
1009        {
1010            declared_in.insert(stem.to_owned(), relative.clone());
1011        }
1012    }
1013
1014    Ok(InstrumentedWorkspace {
1015        project,
1016        build,
1017        modules,
1018        declared_in,
1019        added_launcher,
1020        serialised,
1021        concurrent,
1022        added_vintage,
1023        relaxed,
1024        unmeasurable,
1025        modular,
1026    })
1027}
1028
1029/// The fingerprint a later query compares against the stored run.
1030pub fn current_jvm_integrity(
1031    root: &Path,
1032    command: &[String],
1033    source_roots: Option<&[String]>,
1034) -> Result<crate::run_store::RunIntegrity, String> {
1035    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1036    let files = crate::jvm_project::discover_jvm_files(&root)?;
1037    let mut inputs = jvm_integrity_inputs(&files, command);
1038    crate::source_discovery::fold_roots_into_configuration(
1039        &mut inputs.execution_configuration,
1040        source_roots,
1041    );
1042    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_jvm())
1043        .map_err(|error| error.to_string())
1044}
1045
1046pub fn run_direct_jvm(
1047    request: &DirectJvmRunRequest,
1048    diagnostics: &mut dyn Write,
1049) -> Result<DirectJvmRunResult, String> {
1050    if request.command.is_empty() {
1051        return Err("test command must not be empty".into());
1052    }
1053    let total_started = Instant::now();
1054    let initialization_started = Instant::now();
1055    let root = canonicalize_simplified(&request.root)
1056        .map_err(|error| format!("{}: {error}", request.root.display()))?;
1057    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1058        .map_err(|error| error.to_string())?;
1059    let initialization_ms = elapsed_ms(initialization_started);
1060    let work_directory = root.join(".supercov/work").join(&request.run_id);
1061    let result = (|| {
1062        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1063            .map_err(|error| error.to_string())?;
1064        if !recovered_runs.is_empty() {
1065            writeln!(
1066                diagnostics,
1067                "[supercov] recovered abandoned run(s): {}",
1068                recovered_runs.join(", ")
1069            )
1070            .map_err(|error| error.to_string())?;
1071        }
1072
1073        let adapter_started = Instant::now();
1074        let files = crate::jvm_project::discover_jvm_files(&root)?;
1075        let source_roots = crate::source_discovery::configured_source_roots(
1076            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1077        );
1078        let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1079        crate::source_discovery::fold_roots_into_configuration(
1080            &mut integrity_inputs.execution_configuration,
1081            source_roots.as_deref(),
1082        );
1083        let assertion_inputs =
1084            crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1085        let integrity = create_explicit_run_integrity(
1086            &root,
1087            &integrity_inputs,
1088            &FrontendIntegrityInputs::embedded_jvm(),
1089        )
1090        .map_err(|error| error.to_string())?;
1091
1092        let workspace_started = Instant::now();
1093        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1094        let workspace =
1095            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1096        let evidence_directory = work_directory.join("jvm");
1097        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1098        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1099        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1100            &root, &workspace, &ambient,
1101        )
1102        .map_err(|error| error.to_string())?;
1103        let instrumented = instrument_workspace(
1104            &workspace,
1105            &evidence_directory,
1106            request.exact_attribution,
1107            roots.as_ref(),
1108        )?;
1109        let workspace_preparation_ms = elapsed_ms(workspace_started);
1110        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1111        writeln!(
1112            diagnostics,
1113            "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1114            match instrumented.build {
1115                JvmBuild::Maven => "a Maven project",
1116                JvmBuild::Gradle => "a Gradle project",
1117                JvmBuild::Plain => "Java/Kotlin sources",
1118            },
1119            instrumented.project.instrumented.len(),
1120            workspace.display()
1121        )
1122        .map_err(|error| error.to_string())?;
1123        if let Some(build_file) = instrumented.added_launcher {
1124            writeln!(
1125                diagnostics,
1126                "[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."
1127            )
1128            .map_err(|error| error.to_string())?;
1129        }
1130        if !instrumented.serialised.is_empty() {
1131            writeln!(
1132                diagnostics,
1133                "[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.",
1134                instrumented
1135                    .serialised
1136                    .iter()
1137                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1138                    .collect::<Vec<_>>()
1139                    .join(", ")
1140            )
1141            .map_err(|error| error.to_string())?;
1142        }
1143        if !instrumented.concurrent.is_empty() {
1144            writeln!(
1145                diagnostics,
1146                "[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.",
1147                instrumented
1148                    .concurrent
1149                    .iter()
1150                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1151                    .collect::<Vec<_>>()
1152                    .join(", ")
1153            )
1154            .map_err(|error| error.to_string())?;
1155        }
1156        if !instrumented.relaxed.is_empty() {
1157            writeln!(
1158                diagnostics,
1159                "[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.",
1160                instrumented.relaxed.join("; ")
1161            )
1162            .map_err(|error| error.to_string())?;
1163        }
1164        if instrumented.added_vintage {
1165            writeln!(
1166                diagnostics,
1167                "[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."
1168            )
1169            .map_err(|error| error.to_string())?;
1170        }
1171        if !instrumented.modular.is_empty() {
1172            writeln!(
1173                diagnostics,
1174                "[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.",
1175                instrumented.modular.len(),
1176                instrumented.modular.join(", ")
1177            )
1178            .map_err(|error| error.to_string())?;
1179        }
1180        if !instrumented.unmeasurable.is_empty() {
1181            writeln!(
1182                diagnostics,
1183                "[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.",
1184                instrumented.unmeasurable.len(),
1185                instrumented.unmeasurable.join(", ")
1186            )
1187            .map_err(|error| error.to_string())?;
1188        }
1189        for (file, reason) in &instrumented.project.unparseable {
1190            writeln!(
1191                diagnostics,
1192                "[supercov] could not parse {file}: {reason}; it carries no obligations"
1193            )
1194            .map_err(|error| error.to_string())?;
1195        }
1196
1197        let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1198        if let Some(note) = note {
1199            writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1200        }
1201        let test_started = Instant::now();
1202        let plan = ExecutionPlan {
1203            preparation: Vec::new(),
1204            test: ExecutionPhase {
1205                name: "test".into(),
1206                kind: PhaseKind::Test,
1207                command: CommandSpec {
1208                    program: command[0].clone().into(),
1209                    arguments: command[1..].iter().map(OsString::from).collect(),
1210                    cwd: workspace.clone(),
1211                    environment: None,
1212                    captured_output: None,
1213                },
1214            },
1215        };
1216        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1217        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1218            .map_err(|error| error.to_string())?;
1219        let test_command_ms = elapsed_ms(test_started);
1220        if let Some(signal) = execution.interrupted_signal {
1221            return Err(format!(
1222                "the test command was interrupted by {signal:?}; no run was published"
1223            ));
1224        }
1225        let exit_code = execution.exit_code;
1226
1227        let publication_started = Instant::now();
1228        // One JVM per module, so one evidence file per module.
1229        let mut parts = Vec::new();
1230        let mut outcomes = Vec::new();
1231        let mut silent = Vec::new();
1232        for module in instrumented
1233            .modules
1234            .iter()
1235            .filter(|module| module.has_tests)
1236        {
1237            // Every JVM the build forked for this module wrote its own file.
1238            let mut written = fs::read_dir(&module.evidence)
1239                .map(|entries| {
1240                    entries
1241                        .flatten()
1242                        .map(|entry| entry.path())
1243                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1244                        .collect::<Vec<_>>()
1245                })
1246                .unwrap_or_default();
1247            written.sort();
1248            if written.is_empty() {
1249                silent.push(module.directory.clone());
1250                continue;
1251            }
1252            let mut forked = Vec::new();
1253            for path in &written {
1254                let bytes =
1255                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1256                forked.push(
1257                    read_evidence(&bytes)
1258                        .map_err(|error| format!("{}: {error}", path.display()))?,
1259                );
1260            }
1261            let evidence = merge_evidence(forked);
1262            for test in &evidence.tests {
1263                outcomes.push(OwnedTestOutcome {
1264                    name: test.name.clone(),
1265                    runner: test.runner.clone(),
1266                    // The module is the unit that forked a JVM of its own, so
1267                    // it is what a worker identity means here. The class is
1268                    // already in the name the framework reported.
1269                    package: module.directory.clone(),
1270                    file: class_of(&test.name)
1271                        .and_then(|class| instrumented.declared_in.get(class))
1272                        .cloned(),
1273                    status: test.status.clone(),
1274                    // True while the suite runs its tests one at a time, which
1275                    // is what `--exact-attribution` asks for. Left to run as
1276                    // written, JUnit may run them at once; the runtime says so
1277                    // per record, and a test that overlapped is credited with
1278                    // nothing rather than with a neighbour's work.
1279                    attributed: !test.unattributed,
1280                });
1281            }
1282            parts.push(evidence);
1283        }
1284        if !silent.is_empty() {
1285            writeln!(
1286                diagnostics,
1287                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1288                silent.len(),
1289                silent.join(", ")
1290            )
1291            .map_err(|error| error.to_string())?;
1292        }
1293        if outcomes.is_empty() {
1294            return Err(format!(
1295                "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."
1296            ));
1297        }
1298        let evidence = merge_evidence(parts);
1299        let run = build_frontend_run(OwnedRunInputs {
1300            declaration: jvm_declaration(),
1301            environment: "jvm",
1302            manifest: &instrumented.project.manifest,
1303            probes: &instrumented.project.probes,
1304            evidence: &evidence,
1305            outcomes: &outcomes,
1306            run_id: &request.run_id,
1307            generated_at: &request.started_at,
1308            test_exit_code: exit_code,
1309            coverage_model: jvm_coverage_model(),
1310        })
1311        .map_err(|error| error.to_string())?;
1312        validate_frontend_report_request(&run.declaration, &run.request)
1313            .map_err(|error| error.to_string())?;
1314        let archive_path = work_directory.join("evidence.raw.gz");
1315        let raw = write_archive(
1316            crate::assertion_inputs::append(
1317                run.archive_entries().map_err(|error| error.to_string())?,
1318                &assertion_inputs,
1319            )?,
1320            &archive_path,
1321        )
1322        .map_err(|error| error.to_string())?;
1323        let evidence_publication_ms = elapsed_ms(publication_started);
1324
1325        let timings = RunTimings {
1326            initialization_ms,
1327            workspace_preparation_ms,
1328            adapter_setup_ms,
1329            instrumented_build_ms: 0.0,
1330            test_command_ms,
1331            evidence_publication_ms,
1332        };
1333        let metadata = RunMetadata {
1334            id: request.run_id.clone(),
1335            started_at: request.started_at.clone(),
1336            duration_ms: elapsed_ms(total_started),
1337            command: request.command.clone(),
1338            test_exit_code: Some(exit_code),
1339            integrity,
1340            raw_evidence: RawEvidenceMetadata {
1341                schema_version: raw.schema_version,
1342                format: raw.format.into(),
1343                file: raw.file.into(),
1344                files: raw.files,
1345                uncompressed_bytes: raw.uncompressed_bytes,
1346                compressed_bytes: raw.compressed_bytes,
1347            },
1348            isolated_build: Some(true),
1349            instrumented_build_cache: None,
1350            timings: Some(timings),
1351            merged: None,
1352            parents: None,
1353            source_roots: source_roots.clone(),
1354        };
1355        let run_directory =
1356            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1357        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1358        Ok(DirectJvmRunResult {
1359            run_id: request.run_id.clone(),
1360            run_directory,
1361            exit_code,
1362            tests: outcomes
1363                .iter()
1364                .map(|outcome| outcome.name.as_str())
1365                .collect::<BTreeSet<_>>()
1366                .len(),
1367            source_files: instrumented.project.instrumented.len(),
1368            modules: instrumented.modules.len(),
1369            build: instrumented.build,
1370            recovered_runs,
1371            metadata,
1372        })
1373    })();
1374    if result.is_err() {
1375        let _ = remove_stored_tree_deferred(&root, &work_directory);
1376    }
1377    let release = lock.release().map_err(|error| error.to_string());
1378    match (result, release) {
1379        (Ok(result), Ok(())) => Ok(result),
1380        (Err(error), _) => Err(error),
1381        (Ok(_), Err(error)) => Err(error),
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388
1389    #[test]
1390    fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1391        // The line is worth printing when something was taken away and noise
1392        // when nothing was: most projects never enable it, and a diagnostic on
1393        // every run is one nobody reads by the time it matters.
1394        assert!(asks_for_parallel_execution(Some(
1395            "junit.jupiter.execution.parallel.enabled=true\n"
1396        )));
1397        assert!(asks_for_parallel_execution(Some(
1398            "  junit.jupiter.execution.parallel.enabled = True  \n"
1399        )));
1400        assert!(!asks_for_parallel_execution(Some(
1401            "junit.jupiter.execution.parallel.enabled=false\n"
1402        )));
1403        // Naming the mode without enabling it leaves the suite sequential, so
1404        // nothing was taken away.
1405        assert!(!asks_for_parallel_execution(Some(
1406            "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1407        )));
1408        assert!(!asks_for_parallel_execution(None));
1409        assert!(!asks_for_parallel_execution(Some("")));
1410    }
1411
1412    #[test]
1413    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1414        // Two tests running at once share one probe array, so neither can be
1415        // credited with what it reached. The runtime notices and says so; this
1416        // is the half that means it never has to.
1417        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1418                        junit.jupiter.execution.parallel.enabled=true\n\
1419                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1420        let updated = sequential_properties(Some(existing));
1421        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1422        assert!(
1423            !updated.contains("parallel.enabled=true"),
1424            "the project's own setting must not survive:\n{updated}"
1425        );
1426        // Everything the project chose for reasons of its own stays.
1427        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1428        assert!(updated.contains("displayname.generator.default"));
1429
1430        // And a project with no properties at all still gets the setting.
1431        assert!(
1432            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1433        );
1434    }
1435
1436    #[test]
1437    fn gradle_is_told_to_run_the_tests_again() {
1438        // Gradle skips a test task it considers up to date, and a task that
1439        // does not run records nothing.
1440        let (command, note) = command_with_fresh_results(
1441            JvmBuild::Gradle,
1442            &["./gradlew".to_owned(), "test".to_owned()],
1443        );
1444        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1445        assert!(note.is_some());
1446
1447        // An author who already said so means it.
1448        let (command, note) = command_with_fresh_results(
1449            JvmBuild::Gradle,
1450            &[
1451                "./gradlew".to_owned(),
1452                "test".to_owned(),
1453                "--rerun".to_owned(),
1454            ],
1455        );
1456        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1457        assert!(note.is_none());
1458
1459        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
1460        let (command, note) =
1461            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1462        assert_eq!(command, ["mvn", "test"]);
1463        assert!(note.is_none());
1464    }
1465
1466    #[test]
1467    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1468        // A Windows path is full of backslashes, and one of them landing
1469        // before a `t` or a `"` would change the path or end the literal.
1470        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1471        assert!(
1472            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1473            "{configuration}"
1474        );
1475        assert!(configuration.contains("PROBES = 7"));
1476        assert!(configuration.contains("new int[] {2, 3}"));
1477    }
1478
1479    #[test]
1480    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1481        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
1482        // reports its own wording under the same class.
1483        assert_eq!(
1484            class_of("CalculatorTest#zeroIsNamed()"),
1485            Some("CalculatorTest")
1486        );
1487        assert_eq!(
1488            class_of("CalculatorSpec#a sum adds its parts"),
1489            Some("CalculatorSpec")
1490        );
1491        assert_eq!(class_of("Standalone"), Some("Standalone"));
1492        assert_eq!(class_of(""), None);
1493    }
1494
1495    #[test]
1496    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1497        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
1498        // Jupiter says exactly which launcher agrees with the engine that will
1499        // run. Guessing instead could pair a launcher with an engine it does
1500        // not understand.
1501        assert_eq!(
1502            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1503                .as_deref(),
1504            Some("1.10.2")
1505        );
1506        assert_eq!(
1507            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1508                .as_deref(),
1509            Some("1.13.1")
1510        );
1511        // A project using the BOM has already said how every JUnit artifact is
1512        // versioned; naming one would override the answer it gave.
1513        assert_eq!(
1514            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1515            None
1516        );
1517        // And one that says nothing gets a launcher new enough to drive an
1518        // older engine, which is the direction that works.
1519        assert_eq!(
1520            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1521            Some(DEFAULT_LAUNCHER_VERSION)
1522        );
1523        // Including a file that names no JUnit artifact at all -- an
1524        // aggregating parent, say, whose children each declare their own.
1525        // Reading that as "a BOM manages it" writes a versionless dependency
1526        // into a pom with no BOM to resolve it, and the build stops before a
1527        // single test runs.
1528        assert_eq!(
1529            launcher_version("<artifactId>parent</artifactId>").as_deref(),
1530            Some(DEFAULT_LAUNCHER_VERSION)
1531        );
1532        assert_eq!(
1533            launcher_version("").as_deref(),
1534            Some(DEFAULT_LAUNCHER_VERSION)
1535        );
1536    }
1537
1538    #[test]
1539    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1540        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";
1541        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1542        assert!(updated.contains("junit-platform-launcher"), "{updated}");
1543        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1544        // Inside the existing block, not after it.
1545        assert!(
1546            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1547            "{updated}"
1548        );
1549        // A project that already has it is left exactly as it is.
1550        assert_eq!(maven_with_launcher(&updated), None);
1551
1552        // And one with no dependencies block at all still gets a valid pom.
1553        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
1554        let updated = maven_with_launcher(bare).expect("a block is created");
1555        assert!(updated.contains("<dependencies>"), "{updated}");
1556        assert!(
1557            updated.find("</dependencies>") < updated.find("</project>"),
1558            "{updated}"
1559        );
1560    }
1561
1562    #[test]
1563    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1564        let groovy =
1565            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1566        let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1567        assert!(
1568            updated
1569                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1570            "{updated}"
1571        );
1572        // The project's own block survives: Gradle merges what we append.
1573        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1574        assert_eq!(gradle_with_launcher(&updated, false), None);
1575
1576        // Gradle 9 makes every project declare the launcher, and the
1577        // configuration its own documentation recommends is testRuntimeOnly,
1578        // which the tests run with but do not compile against. A project
1579        // following that advice has the artifact and still cannot compile a
1580        // listener, so it gets a compile-visible declaration alongside.
1581        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1582        let updated = gradle_with_launcher(runtime_only, false)
1583            .expect("a runtime-only declaration does not reach the compiler");
1584        assert!(
1585            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1586            "{updated}"
1587        );
1588        assert!(
1589            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1590            "the project's own declaration stays:\n{updated}"
1591        );
1592
1593        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1594        let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1595        assert!(
1596            updated.contains(
1597                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1598            ),
1599            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1600        );
1601        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1602    }
1603
1604    #[test]
1605    fn only_the_listeners_a_project_can_compile_are_written() {
1606        // Each listener is compiled from the project's own test sources, so
1607        // one whose framework is absent would fail on imports the project
1608        // never asked for.
1609        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1610        assert!(junit.platform && !junit.testng);
1611
1612        let testng = frameworks("<artifactId>testng</artifactId>");
1613        assert!(testng.testng && !testng.platform);
1614
1615        // A migration in progress runs both, and both listeners fire in one
1616        // JVM against one runtime.
1617        let both = frameworks("testng ... junit-jupiter");
1618        assert!(both.platform && both.testng);
1619
1620        // Kotest and Spock are platform engines, so the platform listener
1621        // reports their tests without either being named.
1622        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1623        assert!(frameworks("org.spockframework:spock-core").platform);
1624
1625        // And a project naming nothing recognisable gets the platform, which
1626        // is what nearly every JVM suite runs on.
1627        let unknown = frameworks("<artifactId>demo</artifactId>");
1628        assert!(unknown.platform && !unknown.testng);
1629    }
1630
1631    #[test]
1632    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1633        // Surefire picks its provider from what is on the classpath. Adding
1634        // the platform launcher to a JUnit 4 project makes it choose the
1635        // platform provider, find no engine there, and fail the suite --
1636        // Supercov breaking a build it was asked to measure. The word "junit"
1637        // appears in both, so the artifact is what tells them apart.
1638        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1639        assert!(four.junit4 && !four.platform && !four.testng);
1640
1641        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1642        assert!(five.platform && !five.junit4);
1643
1644        // Vintage runs JUnit 4 tests on the platform, so a project with both
1645        // is a platform project.
1646        let both = frameworks(
1647            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1648        );
1649        assert!(both.platform && !both.junit4);
1650
1651        // Gradle spells its dependencies differently and means the same.
1652        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1653        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1654        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1655    }
1656
1657    #[test]
1658    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1659        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
1660        // requires com.google.gson, junit and truth and nothing else. A
1661        // listener written into it imports org.junit.platform -- not visible
1662        // -- and reads a runtime from a package the main module does not
1663        // export. The module stops compiling and takes the reactor with it,
1664        // for tests that check module boundaries rather than product logic.
1665        let root = std::env::temp_dir().join(format!(
1666            "supercov-jpms-{}-{}",
1667            std::process::id(),
1668            std::time::SystemTime::now()
1669                .duration_since(std::time::UNIX_EPOCH)
1670                .unwrap()
1671                .as_nanos()
1672        ));
1673        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();
1674        for module in ["lib", "boundaries"] {
1675            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1676            write(
1677                &root.join(module).join("src/main/java/app/Api.java"),
1678                "package app;\npublic class Api { public int one() { return 1; } }\n",
1679            )
1680            .unwrap();
1681            write(
1682                &root.join(module).join("src/test/java/app/ApiTest.java"),
1683                "package app;\nclass ApiTest { void t() {} }\n",
1684            )
1685            .unwrap();
1686        }
1687        write(
1688            &root.join("boundaries/src/test/java/module-info.java"),
1689            "module app.boundaries {\n  requires app.lib;\n}\n",
1690        )
1691        .unwrap();
1692
1693        let evidence = root.join("evidence");
1694        let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
1695        assert_eq!(instrumented.modular, ["boundaries"]);
1696
1697        // The listener goes into the ordinary module and not the named one.
1698        assert!(
1699            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1700                .exists()
1701        );
1702        for name in ["SupercovListener.java", "SupercovConfig.java"] {
1703            assert!(
1704                !root
1705                    .join("boundaries/src/test/java/com/supercorp/supercov")
1706                    .join(name)
1707                    .exists(),
1708                "{name} must not be written into a named module"
1709            );
1710        }
1711        assert!(
1712            !root
1713                .join("boundaries/src/test/resources")
1714                .join(SERVICES_FILE)
1715                .exists(),
1716            "and nothing registers a listener that is not there"
1717        );
1718        // Its product code is still instrumented, and the runtime it stores
1719        // into is a package of the module's own -- which a named module may
1720        // hold without exporting.
1721        assert!(
1722            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1723                .exists()
1724        );
1725
1726        fs::remove_dir_all(root).ok();
1727    }
1728
1729    #[test]
1730    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1731        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
1732        // without the catalog the build file names no framework at all, the
1733        // fallback takes it for a platform project, and a listener goes in
1734        // that nothing will ever call: the suite passes and records nothing.
1735        let root = std::env::temp_dir().join(format!(
1736            "supercov-catalog-{}-{}",
1737            std::process::id(),
1738            std::time::SystemTime::now()
1739                .duration_since(std::time::UNIX_EPOCH)
1740                .unwrap()
1741                .as_nanos()
1742        ));
1743        write(
1744            &root.join("gradle/libs.versions.toml"),
1745            "[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",
1746        )
1747        .unwrap();
1748        let catalog = version_catalog(&root);
1749
1750        let junit4 = frameworks(&with_catalog(
1751            "dependencies { testImplementation(libs.junit) }",
1752            &catalog,
1753        ));
1754        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1755
1756        // An accessor is a prefix of a longer one, and must not answer for it.
1757        let reflect = frameworks(&with_catalog(
1758            "dependencies { testImplementation(libs.kotlin.reflect) }",
1759            &catalog,
1760        ));
1761        assert!(!reflect.junit4, "{reflect:?}");
1762
1763        let jupiter = frameworks(&with_catalog(
1764            "dependencies { testImplementation(libs.jupiter) }",
1765            &catalog,
1766        ));
1767        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1768
1769        // A bundle stands for every library in it.
1770        let bundle = frameworks(&with_catalog(
1771            "dependencies { testImplementation(libs.bundles.unit) }",
1772            &catalog,
1773        ));
1774        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1775
1776        // A build that names no accessor is unchanged by a catalog.
1777        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1778        assert_eq!(with_catalog(plain, &catalog), plain);
1779
1780        fs::remove_dir_all(root).ok();
1781    }
1782
1783    #[test]
1784    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1785        // A pom's <dependencyManagement> holds a <dependencies> too, and so
1786        // does every profile and plugin. A dependency added there is a version
1787        // for something else to ask for rather than something the project
1788        // depends on: the module compiles exactly as before and the listener
1789        // still cannot find the API it implements.
1790        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";
1791        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1792        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1793        let managed_end = updated
1794            .find("</dependencyManagement>")
1795            .expect("managed block");
1796        let build_start = updated.find("<build>").expect("build block");
1797        assert!(
1798            at > managed_end,
1799            "not among the managed versions:\n{updated}"
1800        );
1801        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1802        // The BOM manages every JUnit artifact, so naming a version would
1803        // override the answer the project already gave.
1804        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1805    }
1806
1807    #[test]
1808    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1809        // A project is free to fail its build on any warning, and good ones
1810        // do. The copy holds code that project never wrote and never agreed a
1811        // style for -- and Error Prone goes further, failing at error severity
1812        // on rules about the shape of a method that no instrumentation can
1813        // satisfy.
1814        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";
1815        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1816        assert!(
1817            updated.contains("<failOnWarning>false</failOnWarning>"),
1818            "{updated}"
1819        );
1820        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1821        // Only the escalation goes: the compiler still compiles what it would
1822        // have, and everything else the project configured is untouched.
1823        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1824
1825        // A project with no such policy is left exactly as it is.
1826        assert_eq!(without_warnings_as_errors("<project></project>"), None);
1827
1828        // Failing on warnings and running a static analyser are separate
1829        // things, and a project may do either without the other.
1830        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1831        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1832        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1833
1834        let only_analyser =
1835            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1836        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1837        assert!(!updated.contains("ErrorProne"), "{updated}");
1838    }
1839
1840    #[test]
1841    fn vintage_carries_the_engine_version_not_the_platform_one() {
1842        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
1843        // vintage 1.N asks for something that was never published and the
1844        // build stops at dependency resolution.
1845        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1846        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1847
1848        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";
1849        let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1850        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1851        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1852        assert!(
1853            updated.contains("<groupId>org.junit.vintage</groupId>"),
1854            "{updated}"
1855        );
1856        // The launcher takes the platform version and the engine the Jupiter
1857        // one, in the same pom.
1858        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1859        assert!(
1860            updated.contains(&format!(
1861                "<version>{}</version>",
1862                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1863            )),
1864            "{updated}"
1865        );
1866
1867        // A project that already has both is left alone.
1868        assert_eq!(maven_with_vintage(&updated), None);
1869    }
1870}