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) -> Result<InstrumentedWorkspace, String> {
717    let project = prepare_jvm_project(workspace)?;
718    let build = detect_build(workspace);
719
720    for (relative, instrumented) in &project.instrumented {
721        write(&workspace.join(relative), instrumented)?;
722    }
723
724    let probe_count = project
725        .probes
726        .keys()
727        .max()
728        .map_or(0, |highest| *highest as usize + 1);
729
730    // Which frameworks a module runs is a question about that module. A
731    // repository can hold a JUnit 4 module beside a JUnit 5 one, and asking
732    // the whole tree at once answers neither: the platform listener would go
733    // into the JUnit 4 module, where it cannot work, and the launcher with it,
734    // where it makes the build choose a provider that finds no engine.
735    let catalog = version_catalog(workspace);
736    let frameworks_of = |directory: &str| -> Frameworks {
737        let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
738        let mut text = names
739            .iter()
740            .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
741            .unwrap_or_default();
742        for name in names {
743            if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
744                text.push('\n');
745                text.push_str(&own);
746                break;
747            }
748        }
749        frameworks(&with_catalog(&text, &catalog))
750    };
751    let mut unmeasurable: Vec<String> = Vec::new();
752    let mut modular: Vec<String> = Vec::new();
753
754    // One entry per module that has a main or a test source set, keyed by
755    // directory so a module contributing both is listed once.
756    let mut modules: BTreeMap<String, bool> = BTreeMap::new();
757    for (relative, _) in &project.files.sources {
758        modules.entry(module_of(relative)).or_insert(false);
759    }
760    for (relative, _) in &project.files.tests {
761        *modules.entry(module_of(relative)).or_default() = true;
762    }
763    // A module's tests may be in a language Supercov does not parse -- Spock
764    // writes them in Groovy, and Supercov measures the Java and Kotlin they
765    // exercise rather than the specification itself. Those files are not in
766    // `files.tests`, so the source sets are asked directly: a module judged to
767    // have no tests gets no listener, and a run with no listener records
768    // nothing at all.
769    for module in modules.keys().cloned().collect::<Vec<_>>() {
770        if workspace.join(&module).join("src/test").is_dir() {
771            modules.insert(module, true);
772        }
773    }
774    if modules.is_empty() {
775        modules.insert(".".to_owned(), true);
776    }
777
778    let modules = modules
779        .into_iter()
780        .map(|(directory, has_tests)| JvmModule {
781            evidence: evidence_directory.join(
782                directory
783                    .chars()
784                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
785                    .collect::<String>(),
786            ),
787            directory,
788            has_tests,
789        })
790        .collect::<Vec<_>>();
791
792    for module in &modules {
793        let frameworks = frameworks_of(&module.directory);
794        let at = |source_set: &str| {
795            workspace
796                .join(&module.directory)
797                .join(source_root(source_set))
798                .join(PACKAGE_DIRECTORY)
799        };
800        // The runtime goes in every module's main source set: instrumented
801        // product code stores into its array, and a module compiles only its
802        // own sources. The class is identical everywhere, and each module's
803        // tests fork a JVM that loads exactly one of them.
804        write(
805            &at("main").join("Supercov.java"),
806            &runtime_source(probe_count),
807        )?;
808        if !module.has_tests {
809            continue;
810        }
811        if frameworks.junit4 && build != JvmBuild::Maven {
812            // Only Maven's copy gets Vintage added below; elsewhere the module
813            // keeps its probes and gets no listener, because attributing it is
814            // impossible and trying would break it.
815            unmeasurable.push(module.directory.clone());
816            continue;
817        }
818        // A test source set with a module-info.java is a named JPMS module,
819        // and a named module is closed: every package it holds is its own and
820        // every dependency has to be declared in that file. A listener written
821        // into it imports org.junit.platform, which the module does not
822        // require, and reads the runtime from a package its main module does
823        // not export -- so the module stops compiling and takes the build with
824        // it. gson's test-jpms is exactly this, and it tests module boundaries
825        // rather than product logic, so leaving it alone costs the report
826        // nothing it could have had.
827        if workspace
828            .join(&module.directory)
829            .join(source_root("test"))
830            .join("module-info.java")
831            .exists()
832        {
833            modular.push(module.directory.clone());
834            continue;
835        }
836        // The listeners and their configuration go in the test source set,
837        // because only the test classpath has the frameworks to listen to.
838        let test = at("test");
839        write(
840            &test.join("SupercovConfig.java"),
841            &configuration(probe_count, &project.decision_widths, &module.evidence),
842        )?;
843        if frameworks.platform || frameworks.junit4 {
844            write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
845        }
846        if frameworks.testng {
847            write(
848                &test.join("SupercovTestNGListener.java"),
849                TESTNG_LISTENER_SOURCE,
850            )?;
851        }
852    }
853
854    // A project's warning policy applies to code it wrote. The copy holds code
855    // it did not.
856    let mut relaxed: Vec<&'static str> = Vec::new();
857    for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
858        let path = workspace.join(name);
859        let Ok(existing) = fs::read_to_string(&path) else {
860            continue;
861        };
862        let Some(updated) = without_warnings_as_errors(&existing) else {
863            continue;
864        };
865        // Named separately, because switching a static analyser off is a
866        // bigger thing than not failing on a warning and the user should hear
867        // it said rather than work it out.
868        if !relaxed.contains(&"stopped the build failing on warnings")
869            && updated.contains("<failOnWarning>false</failOnWarning>")
870                != existing.contains("<failOnWarning>false</failOnWarning>")
871            || existing.contains("-Werror") && !updated.contains("-Werror")
872        {
873            relaxed.push("stopped the build failing on warnings");
874        }
875        if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
876            relaxed.push("switched Error Prone off");
877        }
878        write(&path, &updated)?;
879    }
880    relaxed.dedup();
881
882    // The platform listener is compiled from the project's own test sources,
883    // so the launcher API it implements has to be on the compile classpath. A
884    // TestNG-only project needs none of that: it already depends on the
885    // framework its own listener implements.
886    let mut added_launcher = None;
887    let mut serialised: BTreeSet<String> = BTreeSet::new();
888    let mut concurrent: BTreeSet<String> = BTreeSet::new();
889    let mut added_vintage = false;
890    match build {
891        // Per module, not once at the top: the module's own pom is where its
892        // JUnit version is in scope, and a module that runs JUnit 4 must not
893        // get the launcher at all.
894        JvmBuild::Maven => {
895            for module in modules
896                .iter()
897                .filter(|module| {
898                    module.has_tests
899                        && !unmeasurable.contains(&module.directory)
900                        && !modular.contains(&module.directory)
901                })
902                // A TestNG module needs none of this: it already depends on
903                // the framework its own listener implements, and the launcher
904                // would only change which provider the build chooses.
905                .filter(|module| {
906                    let frameworks = frameworks_of(&module.directory);
907                    frameworks.platform || frameworks.junit4
908                })
909            {
910                let pom = workspace.join(&module.directory).join("pom.xml");
911                let Ok(existing) = fs::read_to_string(&pom) else {
912                    continue;
913                };
914                // A JUnit 4 module also needs the engine that runs JUnit 4
915                // tests on the platform; without it the launcher would find no
916                // engine at all.
917                let updated = if frameworks_of(&module.directory).junit4 {
918                    added_vintage = true;
919                    maven_with_vintage(&existing)
920                } else {
921                    maven_with_launcher(&existing)
922                };
923                if let Some(updated) = updated {
924                    write(&pom, &updated)?;
925                    added_launcher = Some("pom.xml");
926                }
927            }
928        }
929        JvmBuild::Gradle
930            if !modules
931                .iter()
932                .any(|module| module.has_tests && frameworks_of(&module.directory).platform) => {}
933        JvmBuild::Gradle => {
934            for name in ["build.gradle.kts", "build.gradle"] {
935                let path = workspace.join(name);
936                let Ok(existing) = fs::read_to_string(&path) else {
937                    continue;
938                };
939                if let Some(updated) = gradle_with_launcher(&existing, name.ends_with(".kts")) {
940                    write(&path, &updated)?;
941                    added_launcher = Some(if name.ends_with(".kts") {
942                        "build.gradle.kts"
943                    } else {
944                        "build.gradle"
945                    });
946                }
947                break;
948            }
949        }
950        // Nothing resolves dependencies for a plain tree; whoever compiles it
951        // supplies the classpath.
952        JvmBuild::Plain => {}
953    }
954
955    for module in modules.iter().filter(|module| {
956        module.has_tests
957            && !unmeasurable.contains(&module.directory)
958            && !modular.contains(&module.directory)
959    }) {
960        let frameworks = frameworks_of(&module.directory);
961        let resources = workspace.join(&module.directory).join("src/test/resources");
962        if frameworks.platform || frameworks.junit4 {
963            write(
964                &resources.join(SERVICES_FILE),
965                &format!("{LISTENER_CLASS}\n"),
966            )?;
967            let properties = resources.join("junit-platform.properties");
968            let existing = fs::read_to_string(&properties).ok();
969            if !exact_attribution {
970                // The suite runs the way its author wrote it. Where that means
971                // tests run at once the runtime notices, says what it could
972                // not credit, and the run records it -- which is a smaller
973                // price than several times the suite's wall time, and not one
974                // Supercov gets to spend on somebody's behalf.
975                if asks_for_parallel_execution(existing.as_deref()) {
976                    concurrent.insert(module.directory.clone());
977                }
978                continue;
979            }
980            // Said out loud, because it changes how the suite runs rather than
981            // how it is measured. Every other thing Supercov does to the
982            // workspace is announced -- the isolated copy, the added launcher,
983            // `-count=1` over in Go -- and this is the one with a chance of
984            // changing an outcome: a test that waits on another test's thread
985            // passes under the project's own command and hangs under this one.
986            if asks_for_parallel_execution(existing.as_deref()) {
987                serialised.insert(module.directory.clone());
988            }
989            write(&properties, &sequential_properties(existing.as_deref()))?;
990        }
991        if frameworks.testng {
992            write(
993                &resources.join(TESTNG_SERVICES_FILE),
994                &format!("{TESTNG_LISTENER_CLASS}\n"),
995            )?;
996        }
997    }
998
999    // A test class's file, so a result can name where it came from. Matched on
1000    // the class rather than the test, because the name a framework reports for
1001    // a test is its own and need not be a method at all.
1002    let mut declared_in = BTreeMap::new();
1003    for (relative, _) in &project.files.tests {
1004        if let Some(stem) = relative
1005            .rsplit('/')
1006            .next()
1007            .and_then(|name| name.split('.').next())
1008        {
1009            declared_in.insert(stem.to_owned(), relative.clone());
1010        }
1011    }
1012
1013    Ok(InstrumentedWorkspace {
1014        project,
1015        build,
1016        modules,
1017        declared_in,
1018        added_launcher,
1019        serialised,
1020        concurrent,
1021        added_vintage,
1022        relaxed,
1023        unmeasurable,
1024        modular,
1025    })
1026}
1027
1028/// The fingerprint a later query compares against the stored run.
1029pub fn current_jvm_integrity(
1030    root: &Path,
1031    command: &[String],
1032) -> Result<crate::run_store::RunIntegrity, String> {
1033    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1034    let files = crate::jvm_project::discover_jvm_files(&root)?;
1035    create_explicit_run_integrity(
1036        &root,
1037        &jvm_integrity_inputs(&files, command),
1038        &FrontendIntegrityInputs::embedded_jvm(),
1039    )
1040    .map_err(|error| error.to_string())
1041}
1042
1043pub fn run_direct_jvm(
1044    request: &DirectJvmRunRequest,
1045    diagnostics: &mut dyn Write,
1046) -> Result<DirectJvmRunResult, String> {
1047    if request.command.is_empty() {
1048        return Err("test command must not be empty".into());
1049    }
1050    let total_started = Instant::now();
1051    let initialization_started = Instant::now();
1052    let root = canonicalize_simplified(&request.root)
1053        .map_err(|error| format!("{}: {error}", request.root.display()))?;
1054    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1055        .map_err(|error| error.to_string())?;
1056    let initialization_ms = elapsed_ms(initialization_started);
1057    let work_directory = root.join(".supercov/work").join(&request.run_id);
1058    let result = (|| {
1059        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1060            .map_err(|error| error.to_string())?;
1061        if !recovered_runs.is_empty() {
1062            writeln!(
1063                diagnostics,
1064                "[supercov] recovered abandoned run(s): {}",
1065                recovered_runs.join(", ")
1066            )
1067            .map_err(|error| error.to_string())?;
1068        }
1069
1070        let adapter_started = Instant::now();
1071        let files = crate::jvm_project::discover_jvm_files(&root)?;
1072        let integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1073        let assertion_inputs =
1074            crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1075        let integrity = create_explicit_run_integrity(
1076            &root,
1077            &integrity_inputs,
1078            &FrontendIntegrityInputs::embedded_jvm(),
1079        )
1080        .map_err(|error| error.to_string())?;
1081
1082        let workspace_started = Instant::now();
1083        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1084        let workspace =
1085            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1086        let evidence_directory = work_directory.join("jvm");
1087        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1088        let instrumented =
1089            instrument_workspace(&workspace, &evidence_directory, request.exact_attribution)?;
1090        let workspace_preparation_ms = elapsed_ms(workspace_started);
1091        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1092        writeln!(
1093            diagnostics,
1094            "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1095            match instrumented.build {
1096                JvmBuild::Maven => "a Maven project",
1097                JvmBuild::Gradle => "a Gradle project",
1098                JvmBuild::Plain => "Java/Kotlin sources",
1099            },
1100            instrumented.project.instrumented.len(),
1101            workspace.display()
1102        )
1103        .map_err(|error| error.to_string())?;
1104        if let Some(build_file) = instrumented.added_launcher {
1105            writeln!(
1106                diagnostics,
1107                "[supercov] added a test-scoped {LAUNCHER_ARTIFACT} to the workspace's {build_file}: per-test attribution comes from a JUnit Platform listener, and the API it implements is on the test runtime classpath but not the compile one. Your own {build_file} is untouched."
1108            )
1109            .map_err(|error| error.to_string())?;
1110        }
1111        if !instrumented.serialised.is_empty() {
1112            writeln!(
1113                diagnostics,
1114                "[supercov] turned JUnit's parallel execution off in the workspace copy ({}): attribution is a sweep of one shared probe array at each test boundary, so two tests running at once cannot both be credited with what they reached. Your suite runs in order here and your own configuration is untouched.",
1115                instrumented
1116                    .serialised
1117                    .iter()
1118                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1119                    .collect::<Vec<_>>()
1120                    .join(", ")
1121            )
1122            .map_err(|error| error.to_string())?;
1123        }
1124        if !instrumented.concurrent.is_empty() {
1125            writeln!(
1126                diagnostics,
1127                "[supercov] JUnit runs this suite's tests at once ({}), so what they reach while they overlap is recorded against the run rather than against a test. `--exact-attribution` credits each of them instead, by running the suite in order.",
1128                instrumented
1129                    .concurrent
1130                    .iter()
1131                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1132                    .collect::<Vec<_>>()
1133                    .join(", ")
1134            )
1135            .map_err(|error| error.to_string())?;
1136        }
1137        if !instrumented.relaxed.is_empty() {
1138            writeln!(
1139                diagnostics,
1140                "[supercov] in the workspace copy only: {}. The copy holds instrumented code your project never wrote a policy for, and a rule about the shape of a method is one no instrumentation can satisfy. Warnings are still reported, your build file is untouched, and your own build still runs every check in full.",
1141                instrumented.relaxed.join("; ")
1142            )
1143            .map_err(|error| error.to_string())?;
1144        }
1145        if instrumented.added_vintage {
1146            writeln!(
1147                diagnostics,
1148                "[supercov] added junit-vintage-engine to the workspace copy: JUnit 4 is not a JUnit Platform engine, and Vintage is the platform's own way of running exactly these tests through the lifecycle Supercov listens to. Your own build still runs JUnit 4 as it did."
1149            )
1150            .map_err(|error| error.to_string())?;
1151        }
1152        if !instrumented.modular.is_empty() {
1153            writeln!(
1154                diagnostics,
1155                "[supercov] {} module(s) declare their tests as a Java module and are not attributed: {}. A named module names every package it holds and every dependency it may use, in its own module-info.java, so a listener added to it would not compile -- and neither would the module. Supercov leaves those tests to run exactly as they did.",
1156                instrumented.modular.len(),
1157                instrumented.modular.join(", ")
1158            )
1159            .map_err(|error| error.to_string())?;
1160        }
1161        if !instrumented.unmeasurable.is_empty() {
1162            writeln!(
1163                diagnostics,
1164                "[supercov] {} module(s) run JUnit 4, which is not a JUnit Platform engine, so they are not attributed: {}. Supercov listens through the platform's own lifecycle, and putting the platform on a JUnit 4 classpath makes the build choose a provider that finds no engine -- so it leaves those modules alone rather than break them. Adding junit-vintage-engine runs the same tests on the platform, and Supercov measures them.",
1165                instrumented.unmeasurable.len(),
1166                instrumented.unmeasurable.join(", ")
1167            )
1168            .map_err(|error| error.to_string())?;
1169        }
1170        for (file, reason) in &instrumented.project.unparseable {
1171            writeln!(
1172                diagnostics,
1173                "[supercov] could not parse {file}: {reason}; it carries no obligations"
1174            )
1175            .map_err(|error| error.to_string())?;
1176        }
1177
1178        let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1179        if let Some(note) = note {
1180            writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1181        }
1182        let test_started = Instant::now();
1183        let plan = ExecutionPlan {
1184            preparation: Vec::new(),
1185            test: ExecutionPhase {
1186                name: "test".into(),
1187                kind: PhaseKind::Test,
1188                command: CommandSpec {
1189                    program: command[0].clone().into(),
1190                    arguments: command[1..].iter().map(OsString::from).collect(),
1191                    cwd: workspace.clone(),
1192                    environment: None,
1193                    captured_output: None,
1194                },
1195            },
1196        };
1197        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1198        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1199            .map_err(|error| error.to_string())?;
1200        let test_command_ms = elapsed_ms(test_started);
1201        if let Some(signal) = execution.interrupted_signal {
1202            return Err(format!(
1203                "the test command was interrupted by {signal:?}; no run was published"
1204            ));
1205        }
1206        let exit_code = execution.exit_code;
1207
1208        let publication_started = Instant::now();
1209        // One JVM per module, so one evidence file per module.
1210        let mut parts = Vec::new();
1211        let mut outcomes = Vec::new();
1212        let mut silent = Vec::new();
1213        for module in instrumented
1214            .modules
1215            .iter()
1216            .filter(|module| module.has_tests)
1217        {
1218            // Every JVM the build forked for this module wrote its own file.
1219            let mut written = fs::read_dir(&module.evidence)
1220                .map(|entries| {
1221                    entries
1222                        .flatten()
1223                        .map(|entry| entry.path())
1224                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1225                        .collect::<Vec<_>>()
1226                })
1227                .unwrap_or_default();
1228            written.sort();
1229            if written.is_empty() {
1230                silent.push(module.directory.clone());
1231                continue;
1232            }
1233            let mut forked = Vec::new();
1234            for path in &written {
1235                let bytes =
1236                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1237                forked.push(
1238                    read_evidence(&bytes)
1239                        .map_err(|error| format!("{}: {error}", path.display()))?,
1240                );
1241            }
1242            let evidence = merge_evidence(forked);
1243            for test in &evidence.tests {
1244                outcomes.push(OwnedTestOutcome {
1245                    name: test.name.clone(),
1246                    runner: test.runner.clone(),
1247                    // The module is the unit that forked a JVM of its own, so
1248                    // it is what a worker identity means here. The class is
1249                    // already in the name the framework reported.
1250                    package: module.directory.clone(),
1251                    file: class_of(&test.name)
1252                        .and_then(|class| instrumented.declared_in.get(class))
1253                        .cloned(),
1254                    status: test.status.clone(),
1255                    // True while the suite runs its tests one at a time, which
1256                    // is what `--exact-attribution` asks for. Left to run as
1257                    // written, JUnit may run them at once; the runtime says so
1258                    // per record, and a test that overlapped is credited with
1259                    // nothing rather than with a neighbour's work.
1260                    attributed: !test.unattributed,
1261                });
1262            }
1263            parts.push(evidence);
1264        }
1265        if !silent.is_empty() {
1266            writeln!(
1267                diagnostics,
1268                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1269                silent.len(),
1270                silent.join(", ")
1271            )
1272            .map_err(|error| error.to_string())?;
1273        }
1274        if outcomes.is_empty() {
1275            return Err(format!(
1276                "the test run wrote no coverage evidence (the command exited {exit_code}). Supercov attributes through each framework's own lifecycle, so the suite has to run on the JUnit Platform or TestNG."
1277            ));
1278        }
1279        let evidence = merge_evidence(parts);
1280        let run = build_frontend_run(OwnedRunInputs {
1281            declaration: jvm_declaration(),
1282            environment: "jvm",
1283            manifest: &instrumented.project.manifest,
1284            probes: &instrumented.project.probes,
1285            evidence: &evidence,
1286            outcomes: &outcomes,
1287            run_id: &request.run_id,
1288            generated_at: &request.started_at,
1289            test_exit_code: exit_code,
1290            coverage_model: jvm_coverage_model(),
1291        })
1292        .map_err(|error| error.to_string())?;
1293        validate_frontend_report_request(&run.declaration, &run.request)
1294            .map_err(|error| error.to_string())?;
1295        let archive_path = work_directory.join("evidence.raw.gz");
1296        let raw = write_archive(
1297            crate::assertion_inputs::append(
1298                run.archive_entries().map_err(|error| error.to_string())?,
1299                &assertion_inputs,
1300            )?,
1301            &archive_path,
1302        )
1303        .map_err(|error| error.to_string())?;
1304        let evidence_publication_ms = elapsed_ms(publication_started);
1305
1306        let timings = RunTimings {
1307            initialization_ms,
1308            workspace_preparation_ms,
1309            adapter_setup_ms,
1310            instrumented_build_ms: 0.0,
1311            test_command_ms,
1312            evidence_publication_ms,
1313        };
1314        let metadata = RunMetadata {
1315            id: request.run_id.clone(),
1316            started_at: request.started_at.clone(),
1317            duration_ms: elapsed_ms(total_started),
1318            command: request.command.clone(),
1319            test_exit_code: Some(exit_code),
1320            integrity,
1321            raw_evidence: RawEvidenceMetadata {
1322                schema_version: raw.schema_version,
1323                format: raw.format.into(),
1324                file: raw.file.into(),
1325                files: raw.files,
1326                uncompressed_bytes: raw.uncompressed_bytes,
1327                compressed_bytes: raw.compressed_bytes,
1328            },
1329            isolated_build: Some(true),
1330            instrumented_build_cache: None,
1331            timings: Some(timings),
1332            merged: None,
1333            parents: None,
1334        };
1335        let run_directory =
1336            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1337        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1338        Ok(DirectJvmRunResult {
1339            run_id: request.run_id.clone(),
1340            run_directory,
1341            exit_code,
1342            tests: outcomes
1343                .iter()
1344                .map(|outcome| outcome.name.as_str())
1345                .collect::<BTreeSet<_>>()
1346                .len(),
1347            source_files: instrumented.project.instrumented.len(),
1348            modules: instrumented.modules.len(),
1349            build: instrumented.build,
1350            recovered_runs,
1351            metadata,
1352        })
1353    })();
1354    if result.is_err() {
1355        let _ = remove_stored_tree_deferred(&root, &work_directory);
1356    }
1357    let release = lock.release().map_err(|error| error.to_string());
1358    match (result, release) {
1359        (Ok(result), Ok(())) => Ok(result),
1360        (Err(error), _) => Err(error),
1361        (Ok(_), Err(error)) => Err(error),
1362    }
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368
1369    #[test]
1370    fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1371        // The line is worth printing when something was taken away and noise
1372        // when nothing was: most projects never enable it, and a diagnostic on
1373        // every run is one nobody reads by the time it matters.
1374        assert!(asks_for_parallel_execution(Some(
1375            "junit.jupiter.execution.parallel.enabled=true\n"
1376        )));
1377        assert!(asks_for_parallel_execution(Some(
1378            "  junit.jupiter.execution.parallel.enabled = True  \n"
1379        )));
1380        assert!(!asks_for_parallel_execution(Some(
1381            "junit.jupiter.execution.parallel.enabled=false\n"
1382        )));
1383        // Naming the mode without enabling it leaves the suite sequential, so
1384        // nothing was taken away.
1385        assert!(!asks_for_parallel_execution(Some(
1386            "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1387        )));
1388        assert!(!asks_for_parallel_execution(None));
1389        assert!(!asks_for_parallel_execution(Some("")));
1390    }
1391
1392    #[test]
1393    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1394        // Two tests running at once share one probe array, so neither can be
1395        // credited with what it reached. The runtime notices and says so; this
1396        // is the half that means it never has to.
1397        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1398                        junit.jupiter.execution.parallel.enabled=true\n\
1399                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1400        let updated = sequential_properties(Some(existing));
1401        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1402        assert!(
1403            !updated.contains("parallel.enabled=true"),
1404            "the project's own setting must not survive:\n{updated}"
1405        );
1406        // Everything the project chose for reasons of its own stays.
1407        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1408        assert!(updated.contains("displayname.generator.default"));
1409
1410        // And a project with no properties at all still gets the setting.
1411        assert!(
1412            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1413        );
1414    }
1415
1416    #[test]
1417    fn gradle_is_told_to_run_the_tests_again() {
1418        // Gradle skips a test task it considers up to date, and a task that
1419        // does not run records nothing.
1420        let (command, note) = command_with_fresh_results(
1421            JvmBuild::Gradle,
1422            &["./gradlew".to_owned(), "test".to_owned()],
1423        );
1424        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1425        assert!(note.is_some());
1426
1427        // An author who already said so means it.
1428        let (command, note) = command_with_fresh_results(
1429            JvmBuild::Gradle,
1430            &[
1431                "./gradlew".to_owned(),
1432                "test".to_owned(),
1433                "--rerun".to_owned(),
1434            ],
1435        );
1436        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1437        assert!(note.is_none());
1438
1439        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
1440        let (command, note) =
1441            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1442        assert_eq!(command, ["mvn", "test"]);
1443        assert!(note.is_none());
1444    }
1445
1446    #[test]
1447    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1448        // A Windows path is full of backslashes, and one of them landing
1449        // before a `t` or a `"` would change the path or end the literal.
1450        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1451        assert!(
1452            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1453            "{configuration}"
1454        );
1455        assert!(configuration.contains("PROBES = 7"));
1456        assert!(configuration.contains("new int[] {2, 3}"));
1457    }
1458
1459    #[test]
1460    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1461        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
1462        // reports its own wording under the same class.
1463        assert_eq!(
1464            class_of("CalculatorTest#zeroIsNamed()"),
1465            Some("CalculatorTest")
1466        );
1467        assert_eq!(
1468            class_of("CalculatorSpec#a sum adds its parts"),
1469            Some("CalculatorSpec")
1470        );
1471        assert_eq!(class_of("Standalone"), Some("Standalone"));
1472        assert_eq!(class_of(""), None);
1473    }
1474
1475    #[test]
1476    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1477        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
1478        // Jupiter says exactly which launcher agrees with the engine that will
1479        // run. Guessing instead could pair a launcher with an engine it does
1480        // not understand.
1481        assert_eq!(
1482            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1483                .as_deref(),
1484            Some("1.10.2")
1485        );
1486        assert_eq!(
1487            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1488                .as_deref(),
1489            Some("1.13.1")
1490        );
1491        // A project using the BOM has already said how every JUnit artifact is
1492        // versioned; naming one would override the answer it gave.
1493        assert_eq!(
1494            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1495            None
1496        );
1497        // And one that says nothing gets a launcher new enough to drive an
1498        // older engine, which is the direction that works.
1499        assert_eq!(
1500            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1501            Some(DEFAULT_LAUNCHER_VERSION)
1502        );
1503        // Including a file that names no JUnit artifact at all -- an
1504        // aggregating parent, say, whose children each declare their own.
1505        // Reading that as "a BOM manages it" writes a versionless dependency
1506        // into a pom with no BOM to resolve it, and the build stops before a
1507        // single test runs.
1508        assert_eq!(
1509            launcher_version("<artifactId>parent</artifactId>").as_deref(),
1510            Some(DEFAULT_LAUNCHER_VERSION)
1511        );
1512        assert_eq!(
1513            launcher_version("").as_deref(),
1514            Some(DEFAULT_LAUNCHER_VERSION)
1515        );
1516    }
1517
1518    #[test]
1519    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1520        let pom = "<project>\n  <dependencies>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n      <version>5.10.2</version>\n      <scope>test</scope>\n    </dependency>\n  </dependencies>\n</project>\n";
1521        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1522        assert!(updated.contains("junit-platform-launcher"), "{updated}");
1523        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1524        // Inside the existing block, not after it.
1525        assert!(
1526            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1527            "{updated}"
1528        );
1529        // A project that already has it is left exactly as it is.
1530        assert_eq!(maven_with_launcher(&updated), None);
1531
1532        // And one with no dependencies block at all still gets a valid pom.
1533        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
1534        let updated = maven_with_launcher(bare).expect("a block is created");
1535        assert!(updated.contains("<dependencies>"), "{updated}");
1536        assert!(
1537            updated.find("</dependencies>") < updated.find("</project>"),
1538            "{updated}"
1539        );
1540    }
1541
1542    #[test]
1543    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1544        let groovy =
1545            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1546        let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1547        assert!(
1548            updated
1549                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1550            "{updated}"
1551        );
1552        // The project's own block survives: Gradle merges what we append.
1553        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1554        assert_eq!(gradle_with_launcher(&updated, false), None);
1555
1556        // Gradle 9 makes every project declare the launcher, and the
1557        // configuration its own documentation recommends is testRuntimeOnly,
1558        // which the tests run with but do not compile against. A project
1559        // following that advice has the artifact and still cannot compile a
1560        // listener, so it gets a compile-visible declaration alongside.
1561        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1562        let updated = gradle_with_launcher(runtime_only, false)
1563            .expect("a runtime-only declaration does not reach the compiler");
1564        assert!(
1565            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1566            "{updated}"
1567        );
1568        assert!(
1569            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1570            "the project's own declaration stays:\n{updated}"
1571        );
1572
1573        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1574        let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1575        assert!(
1576            updated.contains(
1577                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1578            ),
1579            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1580        );
1581        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1582    }
1583
1584    #[test]
1585    fn only_the_listeners_a_project_can_compile_are_written() {
1586        // Each listener is compiled from the project's own test sources, so
1587        // one whose framework is absent would fail on imports the project
1588        // never asked for.
1589        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1590        assert!(junit.platform && !junit.testng);
1591
1592        let testng = frameworks("<artifactId>testng</artifactId>");
1593        assert!(testng.testng && !testng.platform);
1594
1595        // A migration in progress runs both, and both listeners fire in one
1596        // JVM against one runtime.
1597        let both = frameworks("testng ... junit-jupiter");
1598        assert!(both.platform && both.testng);
1599
1600        // Kotest and Spock are platform engines, so the platform listener
1601        // reports their tests without either being named.
1602        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1603        assert!(frameworks("org.spockframework:spock-core").platform);
1604
1605        // And a project naming nothing recognisable gets the platform, which
1606        // is what nearly every JVM suite runs on.
1607        let unknown = frameworks("<artifactId>demo</artifactId>");
1608        assert!(unknown.platform && !unknown.testng);
1609    }
1610
1611    #[test]
1612    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1613        // Surefire picks its provider from what is on the classpath. Adding
1614        // the platform launcher to a JUnit 4 project makes it choose the
1615        // platform provider, find no engine there, and fail the suite --
1616        // Supercov breaking a build it was asked to measure. The word "junit"
1617        // appears in both, so the artifact is what tells them apart.
1618        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1619        assert!(four.junit4 && !four.platform && !four.testng);
1620
1621        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1622        assert!(five.platform && !five.junit4);
1623
1624        // Vintage runs JUnit 4 tests on the platform, so a project with both
1625        // is a platform project.
1626        let both = frameworks(
1627            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1628        );
1629        assert!(both.platform && !both.junit4);
1630
1631        // Gradle spells its dependencies differently and means the same.
1632        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1633        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1634        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1635    }
1636
1637    #[test]
1638    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1639        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
1640        // requires com.google.gson, junit and truth and nothing else. A
1641        // listener written into it imports org.junit.platform -- not visible
1642        // -- and reads a runtime from a package the main module does not
1643        // export. The module stops compiling and takes the reactor with it,
1644        // for tests that check module boundaries rather than product logic.
1645        let root = std::env::temp_dir().join(format!(
1646            "supercov-jpms-{}-{}",
1647            std::process::id(),
1648            std::time::SystemTime::now()
1649                .duration_since(std::time::UNIX_EPOCH)
1650                .unwrap()
1651                .as_nanos()
1652        ));
1653        write(&root.join("pom.xml"), "<project>\n  <modules>\n    <module>lib</module>\n    <module>boundaries</module>\n  </modules>\n  <dependencies>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n    </dependency>\n  </dependencies>\n</project>\n").unwrap();
1654        for module in ["lib", "boundaries"] {
1655            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1656            write(
1657                &root.join(module).join("src/main/java/app/Api.java"),
1658                "package app;\npublic class Api { public int one() { return 1; } }\n",
1659            )
1660            .unwrap();
1661            write(
1662                &root.join(module).join("src/test/java/app/ApiTest.java"),
1663                "package app;\nclass ApiTest { void t() {} }\n",
1664            )
1665            .unwrap();
1666        }
1667        write(
1668            &root.join("boundaries/src/test/java/module-info.java"),
1669            "module app.boundaries {\n  requires app.lib;\n}\n",
1670        )
1671        .unwrap();
1672
1673        let evidence = root.join("evidence");
1674        let instrumented = instrument_workspace(&root, &evidence, false).expect("instrument");
1675        assert_eq!(instrumented.modular, ["boundaries"]);
1676
1677        // The listener goes into the ordinary module and not the named one.
1678        assert!(
1679            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1680                .exists()
1681        );
1682        for name in ["SupercovListener.java", "SupercovConfig.java"] {
1683            assert!(
1684                !root
1685                    .join("boundaries/src/test/java/com/supercorp/supercov")
1686                    .join(name)
1687                    .exists(),
1688                "{name} must not be written into a named module"
1689            );
1690        }
1691        assert!(
1692            !root
1693                .join("boundaries/src/test/resources")
1694                .join(SERVICES_FILE)
1695                .exists(),
1696            "and nothing registers a listener that is not there"
1697        );
1698        // Its product code is still instrumented, and the runtime it stores
1699        // into is a package of the module's own -- which a named module may
1700        // hold without exporting.
1701        assert!(
1702            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1703                .exists()
1704        );
1705
1706        fs::remove_dir_all(root).ok();
1707    }
1708
1709    #[test]
1710    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1711        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
1712        // without the catalog the build file names no framework at all, the
1713        // fallback takes it for a platform project, and a listener goes in
1714        // that nothing will ever call: the suite passes and records nothing.
1715        let root = std::env::temp_dir().join(format!(
1716            "supercov-catalog-{}-{}",
1717            std::process::id(),
1718            std::time::SystemTime::now()
1719                .duration_since(std::time::UNIX_EPOCH)
1720                .unwrap()
1721                .as_nanos()
1722        ));
1723        write(
1724            &root.join("gradle/libs.versions.toml"),
1725            "[versions]\nkotlin = \"2.0.0\"\n\n[libraries]\njunit = \"junit:junit:4.13.2\"\nkotlin-reflect = { module = \"org.jetbrains.kotlin:kotlin-reflect\", version.ref = \"kotlin\" }\njupiter = { group = \"org.junit.jupiter\", name = \"junit-jupiter\" }\n\n[bundles]\nunit = [\"junit\", \"kotlin-reflect\"]\n",
1726        )
1727        .unwrap();
1728        let catalog = version_catalog(&root);
1729
1730        let junit4 = frameworks(&with_catalog(
1731            "dependencies { testImplementation(libs.junit) }",
1732            &catalog,
1733        ));
1734        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1735
1736        // An accessor is a prefix of a longer one, and must not answer for it.
1737        let reflect = frameworks(&with_catalog(
1738            "dependencies { testImplementation(libs.kotlin.reflect) }",
1739            &catalog,
1740        ));
1741        assert!(!reflect.junit4, "{reflect:?}");
1742
1743        let jupiter = frameworks(&with_catalog(
1744            "dependencies { testImplementation(libs.jupiter) }",
1745            &catalog,
1746        ));
1747        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1748
1749        // A bundle stands for every library in it.
1750        let bundle = frameworks(&with_catalog(
1751            "dependencies { testImplementation(libs.bundles.unit) }",
1752            &catalog,
1753        ));
1754        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1755
1756        // A build that names no accessor is unchanged by a catalog.
1757        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1758        assert_eq!(with_catalog(plain, &catalog), plain);
1759
1760        fs::remove_dir_all(root).ok();
1761    }
1762
1763    #[test]
1764    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1765        // A pom's <dependencyManagement> holds a <dependencies> too, and so
1766        // does every profile and plugin. A dependency added there is a version
1767        // for something else to ask for rather than something the project
1768        // depends on: the module compiles exactly as before and the listener
1769        // still cannot find the API it implements.
1770        let pom = "<project>\n  <dependencyManagement>\n    <dependencies>\n      <dependency>\n        <groupId>org.junit</groupId>\n        <artifactId>junit-bom</artifactId>\n        <version>5.10.2</version>\n      </dependency>\n    </dependencies>\n  </dependencyManagement>\n  <dependencies>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n    </dependency>\n  </dependencies>\n  <build>\n    <plugins>\n      <plugin>\n        <dependencies>\n          <dependency><groupId>x</groupId></dependency>\n        </dependencies>\n      </plugin>\n    </plugins>\n  </build>\n</project>\n";
1771        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1772        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1773        let managed_end = updated
1774            .find("</dependencyManagement>")
1775            .expect("managed block");
1776        let build_start = updated.find("<build>").expect("build block");
1777        assert!(
1778            at > managed_end,
1779            "not among the managed versions:\n{updated}"
1780        );
1781        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1782        // The BOM manages every JUnit artifact, so naming a version would
1783        // override the answer the project already gave.
1784        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1785    }
1786
1787    #[test]
1788    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1789        // A project is free to fail its build on any warning, and good ones
1790        // do. The copy holds code that project never wrote and never agreed a
1791        // style for -- and Error Prone goes further, failing at error severity
1792        // on rules about the shape of a method that no instrumentation can
1793        // satisfy.
1794        let pom = "<project>\n  <failOnWarning>true</failOnWarning>\n  <compilerArgs>\n    <arg>-XDcompilePolicy=simple</arg>\n    <arg>-Xplugin:ErrorProne\n      -Xep:NotJavadoc:OFF\n    </arg>\n  </compilerArgs>\n</project>\n";
1795        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1796        assert!(
1797            updated.contains("<failOnWarning>false</failOnWarning>"),
1798            "{updated}"
1799        );
1800        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1801        // Only the escalation goes: the compiler still compiles what it would
1802        // have, and everything else the project configured is untouched.
1803        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1804
1805        // A project with no such policy is left exactly as it is.
1806        assert_eq!(without_warnings_as_errors("<project></project>"), None);
1807
1808        // Failing on warnings and running a static analyser are separate
1809        // things, and a project may do either without the other.
1810        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1811        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1812        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1813
1814        let only_analyser =
1815            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1816        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1817        assert!(!updated.contains("ErrorProne"), "{updated}");
1818    }
1819
1820    #[test]
1821    fn vintage_carries_the_engine_version_not_the_platform_one() {
1822        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
1823        // vintage 1.N asks for something that was never published and the
1824        // build stops at dependency resolution.
1825        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1826        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1827
1828        let pom = "<project>\n  <dependencies>\n    <dependency>\n      <groupId>junit</groupId>\n      <artifactId>junit</artifactId>\n      <version>4.13.2</version>\n    </dependency>\n  </dependencies>\n</project>\n";
1829        let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1830        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1831        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1832        assert!(
1833            updated.contains("<groupId>org.junit.vintage</groupId>"),
1834            "{updated}"
1835        );
1836        // The launcher takes the platform version and the engine the Jupiter
1837        // one, in the same pom.
1838        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1839        assert!(
1840            updated.contains(&format!(
1841                "<version>{}</version>",
1842                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1843            )),
1844            "{updated}"
1845        );
1846
1847        // A project that already has both is left alone.
1848        assert_eq!(maven_with_vintage(&updated), None);
1849    }
1850}