Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions crates/nub-cli/tests/test_coverage_default_exclusion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
//! Under `--experimental-test-coverage`, nub must report exactly the files the
//! host `node` reports.
//!
//! Node applies its default test-file exclusion (`kDefaultPattern` in
//! `lib/internal/test_runner/utils.js`) only when NO `--test-coverage-exclude` is
//! set, and only from 23.5.0 — it was never backported to 22.x. nub injects an
//! exclude of its own to keep the preloaded runtime out of the report, which
//! silently switched that default off.
//!
//! The contract is parity with whatever `node` this host resolved, so these tests
//! diff nub's table against the live node's rather than against a fixed
//! expectation. That is what makes them version-proof: on 26 both exclude the test
//! file, on 22.15 both include it, and a hardcoded table would be wrong on one of
//! the two.

#![cfg(unix)]

use std::path::{Path, PathBuf};
use std::process::Command;

fn nub_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_nub"))
}

fn host_node_usable() -> bool {
Command::new("node")
.arg("--version")
.output()
.is_ok_and(|out| out.status.success())
}

/// Whether the host node has `--test-coverage-exclude` at all (22.5+). Below it the
/// flag is a bad option and node exits 9, so the user-exclude case has no control to
/// compare against and is skipped rather than asserted.
fn host_node_has_coverage_exclude() -> bool {
Command::new("node")
.args(["--test-coverage-exclude=probe/**", "-e", ""])
.output()
.is_ok_and(|out| out.status.success())
}

struct Fixture {
_temp: tempfile::TempDir,
project: PathBuf,
}

impl Fixture {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
// ESM, not CJS. A CJS `require('node:test')` trips a separate defect on the
// 22.15 tier — upstream's convertCJSFilenameToURL mishandles scheme-only
// builtin ids once any sync resolve hook is registered; fixed by PR #803 —
// and the fixture itself throwing would red this test for a reason it does
// not govern.
std::fs::write(
project.join("package.json"),
"{ \"name\": \"cov\", \"private\": true, \"type\": \"module\" }\n",
)
.unwrap();
std::fs::write(
project.join("logic.js"),
"export const add = (a, b) => a + b;\n",
)
.unwrap();
std::fs::write(
project.join("logic.test.js"),
"import t from 'node:test';\n\
import a from 'node:assert';\n\
import { add } from './logic.js';\n\
t('add', () => { a.strictEqual(add(1, 2), 3); });\n",
)
.unwrap();
Self {
project,
_temp: temp,
}
}

fn command(&self, binary: &Path, extra: &[&str]) -> Command {
let mut command = Command::new(binary);
command
.current_dir(&self.project)
.env("XDG_CONFIG_HOME", self._temp.path().join("config"))
.env("XDG_CACHE_HOME", self._temp.path().join("cache"))
.env_remove("NODE_OPTIONS")
.args([
"--test",
"--experimental-test-coverage",
"--test-reporter=tap",
])
.args(extra);
command
}
}

/// The fixture file names the coverage table lists, in report order. Reducing to
/// names drops the percentages, and keeping only the fixture's own files drops nub's
/// runtime rows — so the comparison is about WHICH of the user's files are reported,
/// which is what the exclusion decides, and is not perturbed by the preload shifting
/// a branch denominator.
fn reported_files(runner: &str, mut command: Command) -> Vec<String> {
let output = command.output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"[{runner}] exited {:?}\nstdout: {stdout}\nstderr: {stderr}",
output.status.code()
);
let start = stdout
.find("# start of coverage report")
.unwrap_or_else(|| panic!("[{runner}] no coverage report in output:\n{stdout}"));
let end = stdout[start..]
.find("# end of coverage report")
.map(|i| start + i)
.unwrap_or(stdout.len());
stdout[start..end]
.lines()
.filter_map(|line| line.strip_prefix("# "))
.filter_map(|row| row.split('|').next())
.map(str::trim)
.filter(|name| *name == "logic.js" || *name == "logic.test.js")
.map(str::to_string)
.collect()
}

/// Both runners see the same fixture and the same flags, so any difference is nub's
/// injected exclude changing which files Node decides to report.
#[test]
fn coverage_reports_the_same_files_as_the_host_node() {
if !host_node_usable() {
eprintln!("skipping coverage default-exclusion: no usable node on PATH");
return;
}
let fixture = Fixture::new();

let node = reported_files("node", fixture.command(Path::new("node"), &[]));
let nub = reported_files("nub", fixture.command(&nub_binary(), &[]));

// Positive control: an empty report on both sides would satisfy the equality
// below while proving nothing. The file under test is covered on every version.
assert!(
node.contains(&"logic.js".to_string()),
"control failed — node reported no coverage for the file under test: {node:?}"
);
assert_eq!(
nub, node,
"nub and node disagree on which files the coverage report lists. nub's \
injected --test-coverage-exclude either turned off Node's default \
test-file exclusion, or re-stated it on a Node that has none."
);
}

/// A user-supplied `--test-coverage-exclude` disables Node's default exclusion for
/// node too, so nub must not re-add it — otherwise nub silently excludes test files
/// a user asked to see.
#[test]
fn a_user_supplied_exclude_matches_the_host_node() {
if !host_node_usable() {
eprintln!("skipping coverage user-exclude parity: no usable node on PATH");
return;
}
if !host_node_has_coverage_exclude() {
eprintln!(
"skipping coverage user-exclude parity: this node predates \
--test-coverage-exclude (22.5), so there is no control to diff against"
);
return;
}
let fixture = Fixture::new();
let user_exclude = ["--test-coverage-exclude=**/no-such-dir/**"];

let node = reported_files("node", fixture.command(Path::new("node"), &user_exclude));
let nub = reported_files("nub", fixture.command(&nub_binary(), &user_exclude));

// Positive control: this exclude matches nothing, so node must still report the
// test file — which is exactly what a re-stated default pattern would take away.
assert!(
node.contains(&"logic.test.js".to_string()),
"control failed — a no-op user exclude should leave node's default \
exclusion off, so the test file stays in the report: {node:?}"
);
assert_eq!(
nub, node,
"nub applied an exclusion node did not, on top of a user-supplied \
--test-coverage-exclude"
);
}
50 changes: 50 additions & 0 deletions crates/nub-core/src/node/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,31 @@ pub(crate) fn test_coverage_exclude_supported(node_version: &NodeVersion) -> boo
*node_version >= MIN_TEST_COVERAGE_EXCLUDE
}

/// Node started excluding the user's own test files from an
/// `--experimental-test-coverage` report by default in **23.5.0** — commit
/// `ea9a675f56`, "test_runner: exclude test files from coverage by default"
/// (nodejs/node#56060), which added the `coverageExcludeGlobs.length === 0`
/// fallback to `kDefaultPattern` in `lib/internal/test_runner/utils.js`.
///
/// It was **never backported to 22.x**, so this floor does NOT coincide with
/// `MIN_TEST_COVERAGE_EXCLUDE` (22.5.0): on 22.5–22.x and 23.0–23.4 the flag
/// exists while the default exclusion does not. That gap is the whole reason this
/// is a separate gate. nub pairs Node's default pattern with its own runtime
/// exclude to stop the exclude from switching that default off — so on a Node that
/// has no such default, injecting the pattern would EXCLUDE test files stock Node
/// includes, breaking parity in the opposite direction.
///
/// Verified empirically on the logic.js/logic.test.js fixture: 18.19.0, 22.15.0,
/// 22.16.0, 22.23.1, 22.23.2, 23.0.0, 23.3.0 and 23.4.0 all report the test file;
/// 23.5.0, 23.6.0, 23.11.0, 24.0.0, 24.17.0, 25.9.0, 26.3.0 and 26.7.0 all exclude
/// it. 22.23.2 is the newest 22.x published, which is what rules out a backport.
const MIN_TEST_COVERAGE_DEFAULT_EXCLUSION: NodeVersion = NodeVersion::new(23, 5, 0);

/// Whether the target Node applies its own default test-file coverage exclusion.
pub(crate) fn test_coverage_default_exclusion_applied(node_version: &NodeVersion) -> bool {
*node_version >= MIN_TEST_COVERAGE_DEFAULT_EXCLUSION
}

/// Compute the flags Nub should inject for the given Node version,
/// after subtracting any user opt-outs from argv and NODE_OPTIONS.
///
Expand Down Expand Up @@ -1066,6 +1091,31 @@ mod tests {
assert!(test_coverage_exclude_supported(&v(24, 0, 0)));
}

#[test]
fn test_coverage_default_exclusion_gated_to_23_5_and_never_backported_to_22() {
// The two coverage gates do NOT share a floor, and treating them as one is
// what breaks parity on 22.x: there the flag exists but Node has no default
// test-file exclusion, so pairing nub's runtime exclude with Node's default
// pattern would hide test files stock Node reports. 22.23.2 is the newest
// 22.x published and still has no default — the line never got the backport.
assert!(!test_coverage_default_exclusion_applied(&v(18, 19, 0)));
assert!(!test_coverage_default_exclusion_applied(&v(22, 15, 0)));
assert!(!test_coverage_default_exclusion_applied(&v(22, 23, 2)));
assert!(!test_coverage_default_exclusion_applied(&v(23, 4, 0)));
assert!(test_coverage_default_exclusion_applied(&v(23, 5, 0)));
assert!(test_coverage_default_exclusion_applied(&v(24, 0, 0)));
assert!(test_coverage_default_exclusion_applied(&v(26, 7, 0)));

// The whole band between the two floors has the flag and not the default.
for version in [v(22, 5, 0), v(22, 23, 2), v(23, 4, 0)] {
assert!(
test_coverage_exclude_supported(&version)
&& !test_coverage_default_exclusion_applied(&version),
"{version:?} must be inside the flag-without-default band"
);
}
}

#[test]
fn strips_gated_node_options_flag_below_floor_both_token_forms() {
// Node 20.0 is below --experimental-webstorage (22.4), --disable-warning
Expand Down
Loading
Loading