diff --git a/src/collect.rs b/src/collect.rs index 477841e..f038362 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -214,7 +214,7 @@ pub(crate) fn collect( project_root, Some(&rustflags), Some(&build_dir), - &cargo_build_args(nextest_args), + &args_for_listing(nextest_args), None, )?; eprintln!( @@ -946,109 +946,122 @@ pub(crate) fn write_nextest_config(project_root: &Path, filter_expr: &str) -> Re Ok(path) } -/// Boolean cargo build flags accepted by both `cargo nextest list` and -/// `cargo nextest run` — no value token follows. -const BUILD_FLAGS_BARE: &[&str] = &[ - "--workspace", - "--all", - "--lib", - "--bins", - "--examples", - "--tests", - "--benches", - "--all-targets", - "--all-features", - "--no-default-features", - "--release", - "-r", - "--frozen", - "--locked", - "--offline", - "--ignore-rust-version", - "--future-incompat-report", - "--unit-graph", +/// Long `cargo nextest run`-only flags with no value token. Dropped from the +/// listing args; `list` would reject every one of them. +const RUN_ONLY_BARE: &[&str] = &[ + "--fail-fast", + "--ff", + "--no-fail-fast", + "--nff", + "--no-run", + "--no-capture", + "--nocapture", + "--no-input-handler", + "--no-output-indent", + "--hide-progress-bar", ]; -/// Long cargo build flags that consume a value — `--flag value` or the -/// joined `--flag=value`. +/// Long `cargo nextest run`-only flags that consume a value. The value may +/// follow as a separate token (`--flag value`) or be joined (`--flag=value`). /// -/// `--target-dir` is deliberately absent: it changes only where artifacts -/// land, not which tests exist, and `collect` already passes its own -/// `--target-dir` to `nextest_list` — forwarding a second one would make -/// `cargo nextest list` reject the duplicate. -const BUILD_FLAGS_VALUED: &[&str] = &[ - "--package", - "--exclude", - "--bin", - "--example", - "--test", - "--bench", - "--features", - "--cargo-profile", - "--target", - "--manifest-path", - "--build-jobs", - "--config", +/// `--message-format` appears on both `list` and `run` but with disjoint +/// value sets — `nextest_list` always passes its own `--message-format json`, +/// so forwarding the user's would either duplicate or break the listing. +const RUN_ONLY_VALUED: &[&str] = &[ + "--retries", + "--max-fail", + "--no-tests", + "--status-level", + "--final-status-level", + "--failure-output", + "--success-output", + "--message-format", + "--message-format-version", + "--max-progress-running", + "--show-progress", + "--stress-count", + "--stress-duration", + "--debugger", + "--tracer", + "--flaky-result", + "--test-threads", + "--jobs", ]; -/// Short cargo build flags that consume a value — `-p mycrate` or the -/// joined `-pmycrate`. -const BUILD_FLAGS_SHORT_VALUED: &[&str] = &["-p", "-F", "-Z"]; +/// Short `cargo nextest run`-only flags that consume a value. +const RUN_ONLY_SHORT_VALUED: &[&str] = &["-j"]; -/// Extract the cargo *build* flags from the post-`--` passthrough so the -/// `cargo nextest list` used for new-test detection builds the same test set -/// as the eventual `cargo nextest run`. +/// Drop `cargo nextest run`-only flags from the post-`--` passthrough so the +/// `cargo nextest list` used for new-test detection enumerates the same test +/// set as the eventual `cargo nextest run`. Everything else — cargo build +/// flags, positional substring filters, `-E`/`--filterset` expressions, +/// `--exact`/`--skip`/`--run-ignored` libtest-compatible options — is shared +/// between `list` and `run` and passes through unchanged. /// -/// `list` and `run` share cargo's build options (`--features`, `-p`, -/// `--release`, …) but `run` adds runner/reporter options (`--retries`, -/// `--no-fail-fast`, `--no-tests`, …) that `list` rejects outright. -/// Forwarding the whole passthrough to `list` would break on any of those; -/// forwarding nothing lists a feature-less build while `run` builds with the -/// user's features, so "listed minus DB = new" compares two different test -/// sets. Hence an allowlist of the build flags — anything else (run-only -/// flags, test-name filters, positionals) is dropped: it either doesn't -/// affect which test binaries get built or `list` wouldn't accept it. -pub(crate) fn cargo_build_args(nextest_args: &[String]) -> Vec { +/// Run-only flags govern execution: failure handling (`--retries`, +/// `--no-fail-fast`, `--max-fail`), test parallelism (`-j`/`--test-threads`), +/// output formatting (`--status-level`, `--message-format`), and progress +/// (`--show-progress`, `--hide-progress-bar`). `list` rejects every one of +/// them. Build flags and filters affect *which* test cases exist and which +/// match — `list` needs them to enumerate the right set with the right +/// `filter-match` tagging. +/// +/// The denylist must stay complete against nextest's CLI. A future +/// `cargo nextest run`-only flag not added here would be forwarded to +/// `cargo nextest list`, which would reject the unknown argument and exit +/// non-zero, surfacing the omission as a hard error during the listing +/// step. That matches this repo's prefer-loud-over-silent stance: the +/// previous build-flag *allowlist* dropped any unknown flag, so a future +/// build flag silently produced a listing that did not match the run. +pub(crate) fn args_for_listing(nextest_args: &[String]) -> Vec { let mut out = Vec::new(); let mut iter = nextest_args.iter(); while let Some(arg) = iter.next() { let name = arg.split('=').next().unwrap_or(arg); - if BUILD_FLAGS_BARE.contains(&name) { - out.push(arg.clone()); - } else if BUILD_FLAGS_VALUED.contains(&name) { - out.push(arg.clone()); - // `--flag value` carries the value in the next token; - // `--flag=value` carries it inline. + if RUN_ONLY_BARE.contains(&name) { + continue; + } + if RUN_ONLY_VALUED.contains(&name) { + // Drop the flag, and (when the value rides as a separate token) + // the value too. if !arg.contains('=') { - if let Some(value) = iter.next() { - out.push(value.clone()); - } - } - } else if BUILD_FLAGS_SHORT_VALUED.contains(&arg.as_str()) { - out.push(arg.clone()); - if let Some(value) = iter.next() { - out.push(value.clone()); + iter.next(); } - } else if BUILD_FLAGS_SHORT_VALUED.iter().any(|s| arg.starts_with(*s)) { - // Joined short form: `-pmycrate`, `-Ffeature`. - out.push(arg.clone()); + continue; } + if RUN_ONLY_SHORT_VALUED.contains(&arg.as_str()) { + iter.next(); + continue; + } + // Joined short form `-j4`. `-j=4` is unusual but the prefix check + // catches it; the name-then-`=` split above already routed `-j=4` + // away from the bare/valued long-flag arms. + if RUN_ONLY_SHORT_VALUED.iter().any(|s| arg.starts_with(*s)) && arg.len() > 2 { + continue; + } + out.push(arg.clone()); } out } /// Result of `cargo nextest list`: every testcase as a (binary_id, test_name) -/// pair, the subset that is ignored, plus per-binary metadata. +/// pair, the subset nextest's filter excludes, plus per-binary metadata. pub(crate) struct Listing { - /// Every testcase nextest enumerated, ignored or not. The complete set — - /// `collect --diff` prunes DB rows against it, so a merely-ignored test - /// must stay in here or its rows would be dropped. + /// Every testcase nextest enumerated, filter-matched or not. The + /// complete set — `collect --diff` prunes DB rows against it, so a test + /// merely excluded by the current filter must stay in here or its rows + /// would be dropped. pub(crate) tests: Vec, - /// Subset of `tests` that nextest reports as `#[ignore]`d on this - /// platform (covers conditional `#[cfg_attr(.., ignore)]` too). These - /// are skipped by `cargo nextest run`, so they never gain coverage; - /// new-test detection must exclude them or they read as "new" forever. - pub(crate) ignored: BTreeSet, + /// Subset of `tests` that nextest reports with `filter-match: { status: + /// "mismatch" }`. `cargo nextest run` skips every test in here, so + /// new-test detection must exclude them or each surfaces as `(new)` + /// every run, and the affected loop must exclude them or stale coverage + /// rows that overlap a hunk would pull a now-skipped test back in. + /// Unifies all four filter sources nextest knows about: `#[ignore]`d + /// tests (`reason: "ignored"`), positional substring filters + /// (`reason: "string"`), `-E`/`--filterset` expressions + /// (`reason: "expression"`), and the project's own `default-filter`. + pub(crate) excluded: BTreeSet, pub(crate) binaries: Vec, } @@ -1071,11 +1084,14 @@ pub(crate) struct BinaryEntry { /// rather than in the project root. Only collect passes this — run/status /// reuse the user's default target/. /// -/// `build_args` are the cargo build flags (`--features`, `-p`, …) extracted -/// from the post-`--` passthrough by [`cargo_build_args`]. They must match +/// `list_args` are the cargo build flags and nextest filters (`--features`, +/// `-p`, positional substrings, `-E` expressions, `--exact`, …) extracted +/// from the post-`--` passthrough by [`args_for_listing`]. They must match /// the build config of the subsequent `cargo nextest run`, or the listing /// enumerates a different test set than the run builds and new-test -/// detection ("listed minus DB") becomes unsound. +/// detection ("listed minus DB") becomes unsound. The filter args additionally +/// tag each testcase with `filter-match.status` so the selection layer can +/// honor positional/`-E` filters the same way nextest run will. /// /// `filter_expr`, when set, passes `-E ` so the listing is restricted to /// tests matching a nextest filterset — used to resolve `[workspace.metadata.affected]` @@ -1084,7 +1100,7 @@ pub(crate) fn nextest_list( project_root: &Path, rustflags_override: Option<&str>, build_dir: Option<&Path>, - build_args: &[String], + list_args: &[String], filter_expr: Option<&str>, ) -> Result { let mut cmd = Command::new("cargo"); @@ -1102,7 +1118,7 @@ pub(crate) fn nextest_list( cmd.arg("--target-dir").arg(dir); cmd.env("LLVM_PROFILE_FILE", dir.join("build-%p-%m.profraw")); } - for a in build_args { + for a in list_args { cmd.arg(a); } if let Some(expr) = filter_expr { @@ -1126,7 +1142,7 @@ pub(crate) fn nextest_list( serde_json::from_str(stdout).context("failed to parse nextest list JSON")?; let mut tests = BTreeSet::new(); - let mut ignored = BTreeSet::new(); + let mut excluded = BTreeSet::new(); let mut binaries = Vec::new(); if let Some(suites) = json.get("rust-suites").and_then(|v| v.as_object()) { for suite in suites.values() { @@ -1148,12 +1164,13 @@ pub(crate) fn nextest_list( }; for (name, case) in cases { let test_id = TestId::new(binary_id.clone(), name.clone()); - let is_ignored = case - .get("ignored") - .and_then(|v| v.as_bool()) - .context("nextest list testcase missing `ignored` flag")?; - if is_ignored { - ignored.insert(test_id.clone()); + let status = case + .get("filter-match") + .and_then(|v| v.get("status")) + .and_then(|v| v.as_str()) + .context("nextest list testcase missing `filter-match.status`")?; + if status != "matches" { + excluded.insert(test_id.clone()); } tests.insert(test_id); } @@ -1161,7 +1178,7 @@ pub(crate) fn nextest_list( } Ok(Listing { tests: tests.into_iter().collect(), - ignored, + excluded, binaries, }) } @@ -1327,7 +1344,7 @@ mod tests { fn listing(binaries: &[(&str, &str)], tests: &[(&str, &str)]) -> Listing { Listing { tests: tests.iter().map(|(b, t)| TestId::new(*b, *t)).collect(), - ignored: BTreeSet::new(), + excluded: BTreeSet::new(), binaries: binaries .iter() .map(|(id, path)| BinaryEntry { @@ -1413,7 +1430,7 @@ mod tests { } #[test] - fn cargo_build_args_keeps_build_flags_drops_run_only() { + fn args_for_listing_drops_run_only_flags() { let args: Vec = [ "--features", "shell-integration-tests", @@ -1426,39 +1443,84 @@ mod tests { .iter() .map(|s| s.to_string()) .collect(); - // `--features ` and `--release` survive; the run-only flags — - // and `--retries`'s separate value token — are dropped. + // Build flags survive; bare `--no-fail-fast`, valued `--retries 2` + // (with its separate value token), and joined `--no-tests=warn` are + // dropped because `cargo nextest list` rejects every one of them. assert_eq!( - cargo_build_args(&args), + args_for_listing(&args), vec!["--features", "shell-integration-tests", "--release"], ); } #[test] - fn cargo_build_args_handles_joined_and_short_forms() { + fn args_for_listing_forwards_positional_and_filterset() { let args: Vec = [ "--features=a,b", "-p", "mycrate", "-r", "--max-fail=3", + "-E", + "test(slow)", "some_test_filter", ] .iter() .map(|s| s.to_string()) .collect(); - // `--flag=value`, `-p `, and the `-r` short flag are build - // args; `--max-fail=3` is run-only and the bare positional filter is - // neither — both dropped. + // Build flags, `-E` filtersets, and positional substring filters all + // pass through — `cargo nextest list` accepts all of them and tags + // each testcase with `filter-match.status` accordingly. Only the + // run-only `--max-fail=3` is dropped. assert_eq!( - cargo_build_args(&args), - vec!["--features=a,b", "-p", "mycrate", "-r"], + args_for_listing(&args), + vec![ + "--features=a,b", + "-p", + "mycrate", + "-r", + "-E", + "test(slow)", + "some_test_filter", + ], ); } #[test] - fn cargo_build_args_empty() { - assert!(cargo_build_args(&[]).is_empty()); + fn args_for_listing_drops_short_and_joined_test_threads() { + // `-j N`, `-j4` (joined), `--jobs N`, and `--test-threads=N` are all + // the same run-only option. + let args: Vec = [ + "-j", + "4", + "--keep1", + "-j8", + "--test-threads=2", + "--jobs", + "1", + "--keep2", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(args_for_listing(&args), vec!["--keep1", "--keep2"],); + } + + #[test] + fn args_for_listing_drops_run_only_aliases() { + // nextest accepts `--nocapture` (alias of `--no-capture`), `--ff` + // (`--fail-fast`), and `--nff` (`--no-fail-fast`) for libtest muscle + // memory; `cargo nextest list` rejects each the same as its canonical + // form, so all three must be dropped. + let args: Vec = ["--nocapture", "--ff", "--keep", "--nff"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(args_for_listing(&args), vec!["--keep"]); + } + + #[test] + fn args_for_listing_empty() { + assert!(args_for_listing(&[]).is_empty()); } /// Regression for the Windows command-line overflow: a large affected set diff --git a/src/plan.rs b/src/plan.rs index d8823ff..530a825 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -5,7 +5,7 @@ //! commands carried their own copies of the listing/diff/config/selection //! pipeline and their own report assembly, each annotated with a comment //! saying it mirrored the other. It had already drifted: `run` listed tests -//! with the caller's build flags (`cargo_build_args(nextest_args)`) while +//! with the caller's build flags (`args_for_listing(nextest_args)`) while //! `status` listed with none, so on any project with feature-gated tests the //! dry run predicted a different test set than the real one, silently and in //! the safe-looking direction (fewer tests listed → fewer reported). @@ -176,9 +176,11 @@ pub(crate) struct Plan { /// List tests, diff against every reachable `collect_sha`, apply /// `[workspace.metadata.affected]` rules, and select. /// -/// `build_args` must be the same flags the caller will hand `nextest run` -/// (via `cargo_build_args`), so new-test detection compares against the test -/// set that will actually be built rather than a feature-less one. +/// `build_args` must be the same args the caller will hand `nextest run` +/// (via `args_for_listing` — build flags plus positional/`-E` filters, with +/// run-only flags dropped), so new-test detection compares against the test +/// set that will actually be built and admitted rather than a feature-less, +/// filter-less one. pub(crate) fn plan( project: &ProjectRoot, db: &Db, diff --git a/src/run.rs b/src/run.rs index d712e41..63df484 100644 --- a/src/run.rs +++ b/src/run.rs @@ -28,7 +28,7 @@ use std::process::Command; use anyhow::{Context, Result}; use crate::collect::{ - cargo_build_args, nextest_filter_expr, require_nextest, write_nextest_config, + args_for_listing, nextest_filter_expr, require_nextest, write_nextest_config, }; use crate::db::{warn_untracked_rs_files, Db, TestId}; use crate::fingerprint; @@ -167,10 +167,14 @@ pub(crate) fn run( } eprintln!("checking for new tests..."); - // The same cargo build flags `run_tests` hands to `nextest run`, so + // List with the same args `run_tests` hands to `nextest run`, so // new-test detection compares against the test set the run actually - // builds — not a feature-less one. - let build_args = cargo_build_args(nextest_args); + // builds and admits — not a feature-less, filter-less one. Run-only + // flags (`--retries`, `--no-fail-fast`, …) are dropped because `list` + // rejects them; positional substring filters and `-E` filtersets pass + // through so each testcase is tagged with the same `filter-match` + // status `nextest run` will apply. + let build_args = args_for_listing(nextest_args); let plan = plan::plan( &project, &db, diff --git a/src/selection.rs b/src/selection.rs index f8441f1..61ea749 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -34,17 +34,22 @@ use crate::project::{ /// Result of the selection computation. pub(crate) struct Selection { /// Known tests selected by line-range overlap with the changed hunks. - /// Excludes `#[ignore]`d tests: their coverage rows can persist from - /// an earlier (non-ignored) collect, but `nextest run` would skip them - /// — same all-ignored-selection rationale as [`new_tests`]. - /// - /// [`new_tests`]: Self::new_tests + /// Excludes tests with `filter-match.status == "mismatch"` — `#[ignore]`d + /// tests whose coverage rows persisted from an earlier collect, tests + /// not matched by a positional/`-E` filter the user passed, and tests + /// the project's own `default-filter` excludes. `nextest run` would + /// skip every one of them; selecting them anyway can collapse the + /// effective set to nothing and trip nextest's "no tests to run" exit. pub(crate) affected: BTreeSet, /// Tests present in the nextest listing but absent from the DB /// entirely under the current fingerprint — added since the last /// `collect`. Always selected because we have no coverage data. - /// Excludes `#[ignore]`d tests: `nextest run` skips them, so they - /// never gain coverage and would otherwise read as "new" on every run. + /// Excludes filter-mismatched tests for the same reason as + /// [`affected`] above: an `#[ignore]`d or filter-excluded test would + /// otherwise read as `(new)` forever, since it never runs and never + /// gains a coverage row. + /// + /// [`affected`]: Self::affected pub(crate) new_tests: BTreeSet, /// Tests present in the nextest listing AND in the DB, but only /// anchored at currently-missing collect_shas. Functionally identical @@ -347,11 +352,15 @@ pub(crate) fn changed_paths_since( /// - `stranded_tests = listed ∩ (all_db_tests - reachable_known_tests)` /// (in DB but only at currently-missing shas). /// -/// `#[ignore]`d tests are dropped from all three sets: `nextest run` skips -/// them, so a selection of nothing but ignored tests makes `nextest run` exit -/// non-zero. New/stranded would re-select an ignored test on every run only -/// for it to be skipped again; `affected` would re-select a test whose -/// coverage rows survived from a previous (non-ignored) collect after a hunk +/// Filter-mismatched tests (`listing.excluded`) are dropped from all three +/// sets: `nextest run` skips every one of them, so a selection of nothing +/// but mismatched tests makes `nextest run` exit non-zero. `excluded` covers +/// `#[ignore]`d tests, tests not matching the user's positional substring +/// filter, tests not matching a `-E`/`--filterset` expression, and tests the +/// project's own `default-filter` excludes — `nextest run`'s exact filter +/// surface. New/stranded would re-select a mismatched test on every run +/// only for it to be skipped again; `affected` would re-select a test whose +/// coverage rows survived from a previous (matching) collect after a hunk /// happens to overlap them. /// /// Both `new` and `stranded` get rerun (and re-anchored, in `collect @@ -380,8 +389,9 @@ pub(crate) fn compute( let mut new_tests = BTreeSet::new(); let mut stranded_tests = BTreeSet::new(); for t in &listed { - if listing.ignored.contains(t) { - // Skipped by `nextest run`, so it never gains coverage — must + if listing.excluded.contains(t) { + // Skipped by `nextest run` (ignored, or excluded by a positional + // / `-E` / default-filter), so it never gains coverage — must // not be treated as a new/stranded test to rerun. Stays in // `listed` (above) so `collect --diff`'s prune keeps its rows. continue; @@ -412,14 +422,21 @@ pub(crate) fn compute( } let hits = db.tests_covering_ranges(env_fingerprint, collect_sha, file, hunks)?; for hit in hits { - if listing.ignored.contains(&hit.test_id) { - // Coverage rows from a previous (non-ignored) collect - // can survive into a state where the test is now - // `#[ignore]`d (the `--diff` prune deliberately keeps - // them — see `diff_collect_keeps_ignored_test_rows`). - // Selecting it anyway produces the same all-ignored + if listing.excluded.contains(&hit.test_id) { + // Coverage rows from a previous matching collect can + // survive into a state where the test now fails the + // current filter (newly `#[ignore]`d, dropped by a + // positional / `-E` / default-filter); the `--diff` + // prune deliberately keeps them (see + // `diff_collect_keeps_ignored_test_rows`). Selecting + // such a test anyway produces the same all-excluded // → nextest exit 4 we filter against above for - // new/stranded. + // new/stranded. Phantoms (in the DB but absent from + // the listing entirely — renamed/deleted) are NOT in + // `listing.excluded` and stay in `affected`; the + // `collect --diff` flow uses the live/phantom split + // in `handle_no_profraw_dirs` to discriminate them + // from a runner-shim failure. continue; } affected.insert(hit.test_id.clone()); @@ -445,11 +462,11 @@ pub(crate) fn compute( // `[workspace.metadata.affected]` rule. Coverage can't link these inputs to tests, so // the rule supplies the edge. A reachable-known test that isn't already // `affected` would otherwise be skipped — rescue it as a `config_test`. - // New/stranded matches already run; ignored ones stay skipped by nextest. + // New/stranded matches already run; filter-excluded ones stay skipped by nextest. let mut config_tests = BTreeSet::new(); for (path, tests) in config_hits { for test in tests { - if listing.ignored.contains(test) + if listing.excluded.contains(test) || affected.contains(test) || !reachable_known.contains(test) { diff --git a/src/status.rs b/src/status.rs index 41b76ad..cb901ae 100644 --- a/src/status.rs +++ b/src/status.rs @@ -13,7 +13,7 @@ use std::path::Path; use anyhow::Result; -use crate::collect::{cargo_build_args, require_nextest}; +use crate::collect::{args_for_listing, require_nextest}; use crate::db::{db_path, warn_untracked_rs_files, Db}; use crate::fingerprint; use crate::plan::{self, Assessment, CacheMiss, CacheState, SelectionReport}; @@ -187,7 +187,7 @@ pub(crate) fn status( &fingerprint.hex, &reach, &changed_files, - &cargo_build_args(nextest_args), + &args_for_listing(nextest_args), detail, )?; let sel = &plan.selection; diff --git a/tests/functional/new_test.rs b/tests/functional/new_test.rs index 175288f..6148b44 100644 --- a/tests/functional/new_test.rs +++ b/tests/functional/new_test.rs @@ -120,7 +120,7 @@ fn ignored_test_not_perpetually_new() { /// later edit to a line that test covered would otherwise pull it into /// `affected` and produce the same all-ignored selection that makes /// `nextest run` exit non-zero. The `affected` loop must filter the -/// `listing.ignored` set just like the new/stranded split does. +/// `listing.excluded` set just like the new/stranded split does. #[test] fn newly_ignored_test_excluded_from_affected() { let tmp = tempfile::tempdir().unwrap(); @@ -176,6 +176,56 @@ fn newly_ignored_test_excluded_from_affected() { ); } +/// `cargo affected run -- ` forwards `` as a positional +/// test-name filter to `cargo nextest run`. New-test detection runs a separate +/// `cargo nextest list` whose build args came from a build-flag *allowlist* +/// that dropped the positional. A new test absent from the DB but not matching +/// `` was therefore still flagged `(new)`, landed in the generated +/// default-filter handed to `nextest run`, then failed to intersect the +/// positional filter — `nextest run` exited 4 (`no tests to run`) once that +/// new test was the only selected entry. +#[test] +fn run_with_positional_filter_excludes_unmatching_new_tests() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + write_two_module_project(dir, "sample_pos_filter_new"); + init_git_with_initial_commit(dir); + + let collect = cargo_affected(dir, &["affected", "collect"]); + assert!( + collect.status.success(), + "collect failed: {}", + String::from_utf8_lossy(&collect.stderr) + ); + + // Add a new test that doesn't match the positional filter we'll run with. + // `test_brand_new` has no `add` substring, so the positional filter will + // exclude it; if new-test detection still flags it `new`, the selection + // collapses to a single test the positional filter doesn't admit. + std::fs::create_dir_all(dir.join("tests")).unwrap(); + std::fs::write( + dir.join("tests/integration_new.rs"), + "#[test]\nfn test_brand_new() {\n assert_eq!(1 + 1, 2);\n}\n", + ) + .unwrap(); + + let run = cargo_affected(dir, &["affected", "run", "-v", "--", "add"]); + assert!( + run.status.success(), + "run with a positional filter that excludes the new test must exit 0, \ + got status={:?} stderr=\n{}\nstdout=\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stderr), + String::from_utf8_lossy(&run.stdout), + ); + let combined = combined_output(&run); + assert!( + !combined.contains("test_brand_new"), + "the new test must not appear in the selection when the positional \ + filter excludes it, got:\n{combined}", + ); +} + /// Single-crate, single-test project: one `add` function and one `test_add` /// covering it. Used as the canonical "the only affected test got ignored" /// case where the selection collapses to empty. @@ -256,7 +306,7 @@ fn new_test_detection_uses_run_features() { /// listed tests with no features at all while `run` listed with the caller's /// — so `status` under-reported a feature-gated new test that `run` would go /// on to execute, which is the one direction a dry run must never be wrong in. -/// Both now list through `plan::plan` with the same `cargo_build_args`, and +/// Both now list through `plan::plan` with the same `args_for_listing`, and /// `status` takes the passthrough that makes that possible. #[test] fn status_prediction_uses_run_features() {