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, note_kept_evidence, publish_run,
43        recover_abandoned_runs, remove_stored_tree_deferred,
44    },
45    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
46    owned_evidence::{
47        OwnedRunInputs, OwnedTestOutcome, build_frontend_run, jvm_coverage_model, jvm_declaration,
48        merge_evidence, read_evidence,
49    },
50    process_supervision::{CommandSpec, SupervisionOptions},
51    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
52    workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
53};
54
55const RUNTIME_SOURCE: &str =
56    include_str!("../runtime-assets/jvm/com/supercorp/supercov/Supercov.java");
57const LISTENER_SOURCE: &str =
58    include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovListener.java");
59
60const TESTNG_LISTENER_SOURCE: &str =
61    include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovTestNGListener.java");
62
63const PACKAGE_DIRECTORY: &str = "com/supercorp/supercov";
64
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    // Whether the tests were measured. A run that failed before that has no
1062    // evidence worth keeping; one that failed after has evidence that cost the
1063    // suite its wall clock.
1064    let measured = std::cell::Cell::new(false);
1065    let result = (|| {
1066        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1067            .map_err(|error| error.to_string())?;
1068        if !recovered_runs.is_empty() {
1069            writeln!(
1070                diagnostics,
1071                "[supercov] recovered abandoned run(s): {}",
1072                recovered_runs.join(", ")
1073            )
1074            .map_err(|error| error.to_string())?;
1075        }
1076
1077        let adapter_started = Instant::now();
1078        let files = crate::jvm_project::discover_jvm_files(&root)?;
1079        let source_roots = crate::source_discovery::configured_source_roots(
1080            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1081        );
1082        let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1083        crate::source_discovery::fold_roots_into_configuration(
1084            &mut integrity_inputs.execution_configuration,
1085            source_roots.as_deref(),
1086        );
1087        let assertion_inputs =
1088            crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1089        let integrity = create_explicit_run_integrity(
1090            &root,
1091            &integrity_inputs,
1092            &FrontendIntegrityInputs::embedded_jvm(),
1093        )
1094        .map_err(|error| error.to_string())?;
1095
1096        let workspace_started = Instant::now();
1097        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1098        let workspace =
1099            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1100        let evidence_directory = work_directory.join("jvm");
1101        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1102        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1103        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1104            &root, &workspace, &ambient,
1105        )
1106        .map_err(|error| error.to_string())?;
1107        let instrumented = instrument_workspace(
1108            &workspace,
1109            &evidence_directory,
1110            request.exact_attribution,
1111            roots.as_ref(),
1112        )?;
1113        let workspace_preparation_ms = elapsed_ms(workspace_started);
1114        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1115        writeln!(
1116            diagnostics,
1117            "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1118            match instrumented.build {
1119                JvmBuild::Maven => "a Maven project",
1120                JvmBuild::Gradle => "a Gradle project",
1121                JvmBuild::Plain => "Java/Kotlin sources",
1122            },
1123            instrumented.project.instrumented.len(),
1124            workspace.display()
1125        )
1126        .map_err(|error| error.to_string())?;
1127        if let Some(build_file) = instrumented.added_launcher {
1128            writeln!(
1129                diagnostics,
1130                "[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."
1131            )
1132            .map_err(|error| error.to_string())?;
1133        }
1134        if !instrumented.serialised.is_empty() {
1135            writeln!(
1136                diagnostics,
1137                "[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.",
1138                instrumented
1139                    .serialised
1140                    .iter()
1141                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1142                    .collect::<Vec<_>>()
1143                    .join(", ")
1144            )
1145            .map_err(|error| error.to_string())?;
1146        }
1147        if !instrumented.concurrent.is_empty() {
1148            writeln!(
1149                diagnostics,
1150                "[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.",
1151                instrumented
1152                    .concurrent
1153                    .iter()
1154                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1155                    .collect::<Vec<_>>()
1156                    .join(", ")
1157            )
1158            .map_err(|error| error.to_string())?;
1159        }
1160        if !instrumented.relaxed.is_empty() {
1161            writeln!(
1162                diagnostics,
1163                "[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.",
1164                instrumented.relaxed.join("; ")
1165            )
1166            .map_err(|error| error.to_string())?;
1167        }
1168        if instrumented.added_vintage {
1169            writeln!(
1170                diagnostics,
1171                "[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."
1172            )
1173            .map_err(|error| error.to_string())?;
1174        }
1175        if !instrumented.modular.is_empty() {
1176            writeln!(
1177                diagnostics,
1178                "[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.",
1179                instrumented.modular.len(),
1180                instrumented.modular.join(", ")
1181            )
1182            .map_err(|error| error.to_string())?;
1183        }
1184        if !instrumented.unmeasurable.is_empty() {
1185            writeln!(
1186                diagnostics,
1187                "[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.",
1188                instrumented.unmeasurable.len(),
1189                instrumented.unmeasurable.join(", ")
1190            )
1191            .map_err(|error| error.to_string())?;
1192        }
1193        for (file, reason) in &instrumented.project.unparseable {
1194            writeln!(
1195                diagnostics,
1196                "[supercov] could not parse {file}: {reason}; it carries no obligations"
1197            )
1198            .map_err(|error| error.to_string())?;
1199        }
1200
1201        let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1202        if let Some(note) = note {
1203            writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1204        }
1205        let test_started = Instant::now();
1206        let plan = ExecutionPlan {
1207            preparation: Vec::new(),
1208            test: ExecutionPhase {
1209                name: "test".into(),
1210                kind: PhaseKind::Test,
1211                command: CommandSpec {
1212                    program: command[0].clone().into(),
1213                    arguments: command[1..].iter().map(OsString::from).collect(),
1214                    cwd: workspace.clone(),
1215                    environment: None,
1216                    captured_output: None,
1217                },
1218            },
1219        };
1220        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1221        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1222            .map_err(|error| error.to_string())?;
1223        let test_command_ms = elapsed_ms(test_started);
1224        if let Some(signal) = execution.interrupted_signal {
1225            return Err(format!(
1226                "the test command was interrupted by {signal:?}; no run was published"
1227            ));
1228        }
1229        let exit_code = execution.exit_code;
1230
1231        let publication_started = Instant::now();
1232        // One JVM per module, so one evidence file per module.
1233        let mut parts = Vec::new();
1234        let mut outcomes = Vec::new();
1235        let mut silent = Vec::new();
1236        for module in instrumented
1237            .modules
1238            .iter()
1239            .filter(|module| module.has_tests)
1240        {
1241            // Every JVM the build forked for this module wrote its own file.
1242            let mut written = fs::read_dir(&module.evidence)
1243                .map(|entries| {
1244                    entries
1245                        .flatten()
1246                        .map(|entry| entry.path())
1247                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1248                        .collect::<Vec<_>>()
1249                })
1250                .unwrap_or_default();
1251            written.sort();
1252            if written.is_empty() {
1253                silent.push(module.directory.clone());
1254                continue;
1255            }
1256            let mut forked = Vec::new();
1257            for path in &written {
1258                let bytes =
1259                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1260                forked.push(
1261                    read_evidence(&bytes)
1262                        .map_err(|error| format!("{}: {error}", path.display()))?,
1263                );
1264            }
1265            let evidence = merge_evidence(forked);
1266            for test in &evidence.tests {
1267                outcomes.push(OwnedTestOutcome {
1268                    name: test.name.clone(),
1269                    runner: test.runner.clone(),
1270                    // The module is the unit that forked a JVM of its own, so
1271                    // it is what a worker identity means here. The class is
1272                    // already in the name the framework reported.
1273                    package: module.directory.clone(),
1274                    file: class_of(&test.name)
1275                        .and_then(|class| instrumented.declared_in.get(class))
1276                        .cloned(),
1277                    status: test.status.clone(),
1278                    // True while the suite runs its tests one at a time, which
1279                    // is what `--exact-attribution` asks for. Left to run as
1280                    // written, JUnit may run them at once; the runtime says so
1281                    // per record, and a test that overlapped is credited with
1282                    // nothing rather than with a neighbour's work.
1283                    attributed: !test.unattributed,
1284                });
1285            }
1286            parts.push(evidence);
1287        }
1288        if !silent.is_empty() {
1289            writeln!(
1290                diagnostics,
1291                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1292                silent.len(),
1293                silent.join(", ")
1294            )
1295            .map_err(|error| error.to_string())?;
1296        }
1297        if outcomes.is_empty() {
1298            return Err(format!(
1299                "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."
1300            ));
1301        }
1302        let evidence = merge_evidence(parts);
1303        measured.set(true);
1304        let run = build_frontend_run(OwnedRunInputs {
1305            declaration: jvm_declaration(),
1306            environment: "jvm",
1307            manifest: &instrumented.project.manifest,
1308            probes: &instrumented.project.probes,
1309            evidence: &evidence,
1310            outcomes: &outcomes,
1311            run_id: &request.run_id,
1312            generated_at: &request.started_at,
1313            test_exit_code: exit_code,
1314            coverage_model: jvm_coverage_model(),
1315        })
1316        .map_err(|error| error.to_string())?;
1317        validate_frontend_report_request(&run.declaration, &run.request)
1318            .map_err(|error| error.to_string())?;
1319        let archive_path = work_directory.join("evidence.raw.gz");
1320        let raw = write_archive(
1321            crate::assertion_inputs::append(
1322                run.archive_entries().map_err(|error| error.to_string())?,
1323                &assertion_inputs,
1324            )?,
1325            &archive_path,
1326        )
1327        .map_err(|error| error.to_string())?;
1328        let evidence_publication_ms = elapsed_ms(publication_started);
1329
1330        let timings = RunTimings {
1331            initialization_ms,
1332            workspace_preparation_ms,
1333            adapter_setup_ms,
1334            instrumented_build_ms: 0.0,
1335            test_command_ms,
1336            evidence_publication_ms,
1337        };
1338        let metadata = RunMetadata {
1339            id: request.run_id.clone(),
1340            started_at: request.started_at.clone(),
1341            duration_ms: elapsed_ms(total_started),
1342            command: request.command.clone(),
1343            test_exit_code: Some(exit_code),
1344            integrity,
1345            raw_evidence: RawEvidenceMetadata {
1346                schema_version: raw.schema_version,
1347                format: raw.format.into(),
1348                file: raw.file.into(),
1349                files: raw.files,
1350                uncompressed_bytes: raw.uncompressed_bytes,
1351                compressed_bytes: raw.compressed_bytes,
1352            },
1353            isolated_build: Some(true),
1354            instrumented_build_cache: None,
1355            timings: Some(timings),
1356            merged: None,
1357            parents: None,
1358            source_roots: source_roots.clone(),
1359        };
1360        let run_directory =
1361            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1362        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1363        Ok(DirectJvmRunResult {
1364            run_id: request.run_id.clone(),
1365            run_directory,
1366            exit_code,
1367            tests: outcomes
1368                .iter()
1369                .map(|outcome| outcome.name.as_str())
1370                .collect::<BTreeSet<_>>()
1371                .len(),
1372            source_files: instrumented.project.instrumented.len(),
1373            modules: instrumented.modules.len(),
1374            build: instrumented.build,
1375            recovered_runs,
1376            metadata,
1377        })
1378    })();
1379    let result = result.map_err(|error| {
1380        if !measured.get() {
1381            return error;
1382        }
1383        note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1384    });
1385    if result.is_err() {
1386        let _ = remove_stored_tree_deferred(&root, &work_directory);
1387    }
1388    let release = lock.release().map_err(|error| error.to_string());
1389    match (result, release) {
1390        (Ok(result), Ok(())) => Ok(result),
1391        (Err(error), _) => Err(error),
1392        (Ok(_), Err(error)) => Err(error),
1393    }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399
1400    #[test]
1401    fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1402        // The line is worth printing when something was taken away and noise
1403        // when nothing was: most projects never enable it, and a diagnostic on
1404        // every run is one nobody reads by the time it matters.
1405        assert!(asks_for_parallel_execution(Some(
1406            "junit.jupiter.execution.parallel.enabled=true\n"
1407        )));
1408        assert!(asks_for_parallel_execution(Some(
1409            "  junit.jupiter.execution.parallel.enabled = True  \n"
1410        )));
1411        assert!(!asks_for_parallel_execution(Some(
1412            "junit.jupiter.execution.parallel.enabled=false\n"
1413        )));
1414        // Naming the mode without enabling it leaves the suite sequential, so
1415        // nothing was taken away.
1416        assert!(!asks_for_parallel_execution(Some(
1417            "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1418        )));
1419        assert!(!asks_for_parallel_execution(None));
1420        assert!(!asks_for_parallel_execution(Some("")));
1421    }
1422
1423    #[test]
1424    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1425        // Two tests running at once share one probe array, so neither can be
1426        // credited with what it reached. The runtime notices and says so; this
1427        // is the half that means it never has to.
1428        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1429                        junit.jupiter.execution.parallel.enabled=true\n\
1430                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1431        let updated = sequential_properties(Some(existing));
1432        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1433        assert!(
1434            !updated.contains("parallel.enabled=true"),
1435            "the project's own setting must not survive:\n{updated}"
1436        );
1437        // Everything the project chose for reasons of its own stays.
1438        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1439        assert!(updated.contains("displayname.generator.default"));
1440
1441        // And a project with no properties at all still gets the setting.
1442        assert!(
1443            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1444        );
1445    }
1446
1447    #[test]
1448    fn gradle_is_told_to_run_the_tests_again() {
1449        // Gradle skips a test task it considers up to date, and a task that
1450        // does not run records nothing.
1451        let (command, note) = command_with_fresh_results(
1452            JvmBuild::Gradle,
1453            &["./gradlew".to_owned(), "test".to_owned()],
1454        );
1455        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1456        assert!(note.is_some());
1457
1458        // An author who already said so means it.
1459        let (command, note) = command_with_fresh_results(
1460            JvmBuild::Gradle,
1461            &[
1462                "./gradlew".to_owned(),
1463                "test".to_owned(),
1464                "--rerun".to_owned(),
1465            ],
1466        );
1467        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1468        assert!(note.is_none());
1469
1470        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
1471        let (command, note) =
1472            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1473        assert_eq!(command, ["mvn", "test"]);
1474        assert!(note.is_none());
1475    }
1476
1477    #[test]
1478    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1479        // A Windows path is full of backslashes, and one of them landing
1480        // before a `t` or a `"` would change the path or end the literal.
1481        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1482        assert!(
1483            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1484            "{configuration}"
1485        );
1486        assert!(configuration.contains("PROBES = 7"));
1487        assert!(configuration.contains("new int[] {2, 3}"));
1488    }
1489
1490    #[test]
1491    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1492        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
1493        // reports its own wording under the same class.
1494        assert_eq!(
1495            class_of("CalculatorTest#zeroIsNamed()"),
1496            Some("CalculatorTest")
1497        );
1498        assert_eq!(
1499            class_of("CalculatorSpec#a sum adds its parts"),
1500            Some("CalculatorSpec")
1501        );
1502        assert_eq!(class_of("Standalone"), Some("Standalone"));
1503        assert_eq!(class_of(""), None);
1504    }
1505
1506    #[test]
1507    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1508        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
1509        // Jupiter says exactly which launcher agrees with the engine that will
1510        // run. Guessing instead could pair a launcher with an engine it does
1511        // not understand.
1512        assert_eq!(
1513            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1514                .as_deref(),
1515            Some("1.10.2")
1516        );
1517        assert_eq!(
1518            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1519                .as_deref(),
1520            Some("1.13.1")
1521        );
1522        // A project using the BOM has already said how every JUnit artifact is
1523        // versioned; naming one would override the answer it gave.
1524        assert_eq!(
1525            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1526            None
1527        );
1528        // And one that says nothing gets a launcher new enough to drive an
1529        // older engine, which is the direction that works.
1530        assert_eq!(
1531            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1532            Some(DEFAULT_LAUNCHER_VERSION)
1533        );
1534        // Including a file that names no JUnit artifact at all -- an
1535        // aggregating parent, say, whose children each declare their own.
1536        // Reading that as "a BOM manages it" writes a versionless dependency
1537        // into a pom with no BOM to resolve it, and the build stops before a
1538        // single test runs.
1539        assert_eq!(
1540            launcher_version("<artifactId>parent</artifactId>").as_deref(),
1541            Some(DEFAULT_LAUNCHER_VERSION)
1542        );
1543        assert_eq!(
1544            launcher_version("").as_deref(),
1545            Some(DEFAULT_LAUNCHER_VERSION)
1546        );
1547    }
1548
1549    #[test]
1550    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1551        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";
1552        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1553        assert!(updated.contains("junit-platform-launcher"), "{updated}");
1554        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1555        // Inside the existing block, not after it.
1556        assert!(
1557            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1558            "{updated}"
1559        );
1560        // A project that already has it is left exactly as it is.
1561        assert_eq!(maven_with_launcher(&updated), None);
1562
1563        // And one with no dependencies block at all still gets a valid pom.
1564        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
1565        let updated = maven_with_launcher(bare).expect("a block is created");
1566        assert!(updated.contains("<dependencies>"), "{updated}");
1567        assert!(
1568            updated.find("</dependencies>") < updated.find("</project>"),
1569            "{updated}"
1570        );
1571    }
1572
1573    #[test]
1574    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1575        let groovy =
1576            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1577        let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1578        assert!(
1579            updated
1580                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1581            "{updated}"
1582        );
1583        // The project's own block survives: Gradle merges what we append.
1584        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1585        assert_eq!(gradle_with_launcher(&updated, false), None);
1586
1587        // Gradle 9 makes every project declare the launcher, and the
1588        // configuration its own documentation recommends is testRuntimeOnly,
1589        // which the tests run with but do not compile against. A project
1590        // following that advice has the artifact and still cannot compile a
1591        // listener, so it gets a compile-visible declaration alongside.
1592        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1593        let updated = gradle_with_launcher(runtime_only, false)
1594            .expect("a runtime-only declaration does not reach the compiler");
1595        assert!(
1596            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1597            "{updated}"
1598        );
1599        assert!(
1600            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1601            "the project's own declaration stays:\n{updated}"
1602        );
1603
1604        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1605        let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1606        assert!(
1607            updated.contains(
1608                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1609            ),
1610            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1611        );
1612        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1613    }
1614
1615    #[test]
1616    fn only_the_listeners_a_project_can_compile_are_written() {
1617        // Each listener is compiled from the project's own test sources, so
1618        // one whose framework is absent would fail on imports the project
1619        // never asked for.
1620        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1621        assert!(junit.platform && !junit.testng);
1622
1623        let testng = frameworks("<artifactId>testng</artifactId>");
1624        assert!(testng.testng && !testng.platform);
1625
1626        // A migration in progress runs both, and both listeners fire in one
1627        // JVM against one runtime.
1628        let both = frameworks("testng ... junit-jupiter");
1629        assert!(both.platform && both.testng);
1630
1631        // Kotest and Spock are platform engines, so the platform listener
1632        // reports their tests without either being named.
1633        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1634        assert!(frameworks("org.spockframework:spock-core").platform);
1635
1636        // And a project naming nothing recognisable gets the platform, which
1637        // is what nearly every JVM suite runs on.
1638        let unknown = frameworks("<artifactId>demo</artifactId>");
1639        assert!(unknown.platform && !unknown.testng);
1640    }
1641
1642    #[test]
1643    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1644        // Surefire picks its provider from what is on the classpath. Adding
1645        // the platform launcher to a JUnit 4 project makes it choose the
1646        // platform provider, find no engine there, and fail the suite --
1647        // Supercov breaking a build it was asked to measure. The word "junit"
1648        // appears in both, so the artifact is what tells them apart.
1649        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1650        assert!(four.junit4 && !four.platform && !four.testng);
1651
1652        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1653        assert!(five.platform && !five.junit4);
1654
1655        // Vintage runs JUnit 4 tests on the platform, so a project with both
1656        // is a platform project.
1657        let both = frameworks(
1658            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1659        );
1660        assert!(both.platform && !both.junit4);
1661
1662        // Gradle spells its dependencies differently and means the same.
1663        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1664        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1665        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1666    }
1667
1668    #[test]
1669    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1670        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
1671        // requires com.google.gson, junit and truth and nothing else. A
1672        // listener written into it imports org.junit.platform -- not visible
1673        // -- and reads a runtime from a package the main module does not
1674        // export. The module stops compiling and takes the reactor with it,
1675        // for tests that check module boundaries rather than product logic.
1676        let root = std::env::temp_dir().join(format!(
1677            "supercov-jpms-{}-{}",
1678            std::process::id(),
1679            std::time::SystemTime::now()
1680                .duration_since(std::time::UNIX_EPOCH)
1681                .unwrap()
1682                .as_nanos()
1683        ));
1684        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();
1685        for module in ["lib", "boundaries"] {
1686            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1687            write(
1688                &root.join(module).join("src/main/java/app/Api.java"),
1689                "package app;\npublic class Api { public int one() { return 1; } }\n",
1690            )
1691            .unwrap();
1692            write(
1693                &root.join(module).join("src/test/java/app/ApiTest.java"),
1694                "package app;\nclass ApiTest { void t() {} }\n",
1695            )
1696            .unwrap();
1697        }
1698        write(
1699            &root.join("boundaries/src/test/java/module-info.java"),
1700            "module app.boundaries {\n  requires app.lib;\n}\n",
1701        )
1702        .unwrap();
1703
1704        let evidence = root.join("evidence");
1705        let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
1706        assert_eq!(instrumented.modular, ["boundaries"]);
1707
1708        // The listener goes into the ordinary module and not the named one.
1709        assert!(
1710            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1711                .exists()
1712        );
1713        for name in ["SupercovListener.java", "SupercovConfig.java"] {
1714            assert!(
1715                !root
1716                    .join("boundaries/src/test/java/com/supercorp/supercov")
1717                    .join(name)
1718                    .exists(),
1719                "{name} must not be written into a named module"
1720            );
1721        }
1722        assert!(
1723            !root
1724                .join("boundaries/src/test/resources")
1725                .join(SERVICES_FILE)
1726                .exists(),
1727            "and nothing registers a listener that is not there"
1728        );
1729        // Its product code is still instrumented, and the runtime it stores
1730        // into is a package of the module's own -- which a named module may
1731        // hold without exporting.
1732        assert!(
1733            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1734                .exists()
1735        );
1736
1737        fs::remove_dir_all(root).ok();
1738    }
1739
1740    #[test]
1741    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1742        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
1743        // without the catalog the build file names no framework at all, the
1744        // fallback takes it for a platform project, and a listener goes in
1745        // that nothing will ever call: the suite passes and records nothing.
1746        let root = std::env::temp_dir().join(format!(
1747            "supercov-catalog-{}-{}",
1748            std::process::id(),
1749            std::time::SystemTime::now()
1750                .duration_since(std::time::UNIX_EPOCH)
1751                .unwrap()
1752                .as_nanos()
1753        ));
1754        write(
1755            &root.join("gradle/libs.versions.toml"),
1756            "[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",
1757        )
1758        .unwrap();
1759        let catalog = version_catalog(&root);
1760
1761        let junit4 = frameworks(&with_catalog(
1762            "dependencies { testImplementation(libs.junit) }",
1763            &catalog,
1764        ));
1765        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1766
1767        // An accessor is a prefix of a longer one, and must not answer for it.
1768        let reflect = frameworks(&with_catalog(
1769            "dependencies { testImplementation(libs.kotlin.reflect) }",
1770            &catalog,
1771        ));
1772        assert!(!reflect.junit4, "{reflect:?}");
1773
1774        let jupiter = frameworks(&with_catalog(
1775            "dependencies { testImplementation(libs.jupiter) }",
1776            &catalog,
1777        ));
1778        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1779
1780        // A bundle stands for every library in it.
1781        let bundle = frameworks(&with_catalog(
1782            "dependencies { testImplementation(libs.bundles.unit) }",
1783            &catalog,
1784        ));
1785        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1786
1787        // A build that names no accessor is unchanged by a catalog.
1788        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1789        assert_eq!(with_catalog(plain, &catalog), plain);
1790
1791        fs::remove_dir_all(root).ok();
1792    }
1793
1794    #[test]
1795    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1796        // A pom's <dependencyManagement> holds a <dependencies> too, and so
1797        // does every profile and plugin. A dependency added there is a version
1798        // for something else to ask for rather than something the project
1799        // depends on: the module compiles exactly as before and the listener
1800        // still cannot find the API it implements.
1801        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";
1802        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1803        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1804        let managed_end = updated
1805            .find("</dependencyManagement>")
1806            .expect("managed block");
1807        let build_start = updated.find("<build>").expect("build block");
1808        assert!(
1809            at > managed_end,
1810            "not among the managed versions:\n{updated}"
1811        );
1812        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1813        // The BOM manages every JUnit artifact, so naming a version would
1814        // override the answer the project already gave.
1815        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1816    }
1817
1818    #[test]
1819    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1820        // A project is free to fail its build on any warning, and good ones
1821        // do. The copy holds code that project never wrote and never agreed a
1822        // style for -- and Error Prone goes further, failing at error severity
1823        // on rules about the shape of a method that no instrumentation can
1824        // satisfy.
1825        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";
1826        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1827        assert!(
1828            updated.contains("<failOnWarning>false</failOnWarning>"),
1829            "{updated}"
1830        );
1831        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1832        // Only the escalation goes: the compiler still compiles what it would
1833        // have, and everything else the project configured is untouched.
1834        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1835
1836        // A project with no such policy is left exactly as it is.
1837        assert_eq!(without_warnings_as_errors("<project></project>"), None);
1838
1839        // Failing on warnings and running a static analyser are separate
1840        // things, and a project may do either without the other.
1841        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1842        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1843        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1844
1845        let only_analyser =
1846            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1847        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1848        assert!(!updated.contains("ErrorProne"), "{updated}");
1849    }
1850
1851    #[test]
1852    fn vintage_carries_the_engine_version_not_the_platform_one() {
1853        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
1854        // vintage 1.N asks for something that was never published and the
1855        // build stops at dependency resolution.
1856        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1857        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1858
1859        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";
1860        let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1861        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1862        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1863        assert!(
1864            updated.contains("<groupId>org.junit.vintage</groupId>"),
1865            "{updated}"
1866        );
1867        // The launcher takes the platform version and the engine the Jupiter
1868        // one, in the same pom.
1869        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1870        assert!(
1871            updated.contains(&format!(
1872                "<version>{}</version>",
1873                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1874            )),
1875            "{updated}"
1876        );
1877
1878        // A project that already has both is left alone.
1879        assert_eq!(maven_with_vintage(&updated), None);
1880    }
1881}