Skip to content
Merged
1 change: 1 addition & 0 deletions crates/no-mistakes/src/impacted_checks/generate/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub(crate) fn plan_args_for(
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/napi_api/cli_parity_builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub(crate) fn build_plan_args(options: TestsPlanOptions) -> AnyhowResult<crate::
format: Some(crate::tests::PlanFormat::Json),
json: true,
include_comment: options.include_comment,
include_glob: options.include_glob,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/napi_api/options_flow_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub(crate) struct TestsPlanOptions {
pub(crate) global_config_fallback: Option<bool>,
pub(crate) direct_test_owner: bool,
pub(crate) include_comment: bool,
pub(crate) include_glob: Vec<String>,
}

#[derive(Debug, Default, Deserialize)]
Expand Down
4 changes: 4 additions & 0 deletions crates/no-mistakes/src/tests/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ pub(crate) struct PlanArgs {
/// Include the markdown PR comment on the plan JSON (`comment` field).
#[arg(long = "include-comment", default_value_t = false)]
pub(crate) include_comment: bool,

/// Keep only selected tests whose relative path matches one of these globs.
#[arg(long = "include-glob")]
pub(crate) include_glob: Vec<String>,
}

#[derive(Args, Debug, Clone)]
Expand Down
3 changes: 3 additions & 0 deletions crates/no-mistakes/src/tests/configured_plan/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn vitest_setup_args(root: PathBuf, changed_file: Vec<PathBuf>) -> PlanArgs {
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down Expand Up @@ -388,6 +389,7 @@ fn dependency_trigger_ignores_changed_test_discovery_errors_for_source_changes()
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
};
let prepared = crate::tests::prepared_plan::PreparedTestPlanRequest::prepare(&plan_args)
.expect("fixture request should prepare");
Expand Down Expand Up @@ -481,6 +483,7 @@ fn explicit_ignored_changed_sources_impact_visible_tests_without_ignored_shadows
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
})
.unwrap();
let selected = plan
Expand Down
3 changes: 3 additions & 0 deletions crates/no-mistakes/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ pub struct GroupedExecutionTarget {
pub config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
/// Path-prefix display name, such as a Swift package root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub base_command: Vec<String>,
/// Runner flags without test file paths.
pub runner_args: Vec<String>,
Expand Down
17 changes: 16 additions & 1 deletion crates/no-mistakes/src/tests/plan/changed_inventory.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{generate_plan_with_prepared_inner, PlanArgs, Result, TestPlan};
use crate::codebase::rules::path_filter::GlobMatcher;

pub(crate) fn generate_plan_with_prepared(
args: &PlanArgs,
Expand All @@ -7,6 +8,20 @@ pub(crate) fn generate_plan_with_prepared(
) -> Result<TestPlan> {
let mut plan = generate_plan_with_prepared_inner(args, prepared, timing)?;
plan.changed_files = prepared.changed_file_inventory();
plan.finish(args.include_comment);
retain_include_glob(&mut plan, &args.include_glob)?;
plan.finish(args.include_comment, &prepared.config.tests.swift.packages);
Ok(plan)
}

fn retain_include_glob(plan: &mut TestPlan, patterns: &[String]) -> Result<()> {
if patterns.is_empty() {
return Ok(());
}
let matcher = GlobMatcher::new(patterns, "includeGlob")?;
plan.selected_tests
.retain(|test| matcher.is_match(&test.test_file));
for group in &mut plan.groups {
group.selected.retain(|file| matcher.is_match(file));
Comment thread
jonathanong marked this conversation as resolved.
Comment thread
jonathanong marked this conversation as resolved.
}
Ok(())
}
30 changes: 26 additions & 4 deletions crates/no-mistakes/src/tests/plan_finish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,42 @@ use no_mistakes::codebase::test_discovery::TestExecutionTarget;
use std::collections::BTreeMap;

impl TestPlan {
pub(crate) fn finish(&mut self, include_comment: bool) {
self.execution_targets = grouped_execution_targets(&self.selected_tests);
pub(crate) fn finish(&mut self, include_comment: bool, prefixes: &[String]) {
self.execution_targets = grouped_execution_targets(&self.selected_tests, prefixes);
if include_comment {
self.comment = Some(super::comment::render_markdown_plan(self));
}
}
}

type ExecutionGroupKey = (String, Option<String>, Option<String>, Vec<String>);
type ExecutionGroupKey = (
String,
Option<String>,
Option<String>,
Vec<String>,
Option<String>,
);

fn grouped_execution_targets(selected: &[super::SelectedTest]) -> Vec<GroupedExecutionTarget> {
fn grouped_execution_targets(
selected: &[super::SelectedTest],
prefixes: &[String],
) -> Vec<GroupedExecutionTarget> {
let mut groups: BTreeMap<ExecutionGroupKey, GroupedExecutionTarget> = BTreeMap::new();
for test in selected {
for target in &test.targets {
let name = prefix_name(&test.test_file, prefixes);
Comment thread
jonathanong marked this conversation as resolved.
Outdated
let key = (
target.runner.clone(),
target.config.clone(),
target.project.clone(),
target.base_command.clone(),
name.clone(),
);
let group = groups.entry(key).or_insert_with(|| GroupedExecutionTarget {
runner: target.runner.clone(),
config: target.config.clone(),
project: target.project.clone(),
name,
base_command: target.base_command.clone(),
runner_args: runner_args_without_file(target, &test.test_file),
test_files: Vec::new(),
Expand All @@ -39,6 +51,16 @@ fn grouped_execution_targets(selected: &[super::SelectedTest]) -> Vec<GroupedExe
groups.into_values().collect()
}

fn prefix_name(file: &str, prefixes: &[String]) -> Option<String> {
prefixes
.iter()
.map(|prefix| prefix.trim_end_matches('/'))
.filter(|prefix| !prefix.is_empty())
.filter(|prefix| file == *prefix || file.starts_with(&format!("{prefix}/")))
Comment thread
jonathanong marked this conversation as resolved.
.max_by_key(|prefix| prefix.len())
.map(str::to_string)
}

fn runner_args_without_file(target: &TestExecutionTarget, test_file: &str) -> Vec<String> {
let mut args = target.runner_args.clone();
if args.len() >= 2 && args[args.len() - 2] == "--test" {
Expand Down
56 changes: 44 additions & 12 deletions crates/no-mistakes/src/tests/plan_finish/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ fn selected(file: &str, targets: Vec<TestExecutionTarget>) -> SelectedTest {
/// `base_command`.
#[test]
fn mixed_python_runners_keep_separate_execution_targets() {
let groups = grouped_execution_targets(&[
selected(
"pkg/tests.py",
vec![target("python", Some("pkg"), &["python", "-m", "unittest"])],
),
selected(
"pkg/test_foo.py",
vec![target("python", Some("pkg"), &["pytest"])],
),
]);
let groups = grouped_execution_targets(
&[
selected(
"pkg/tests.py",
vec![target("python", Some("pkg"), &["python", "-m", "unittest"])],
),
selected(
"pkg/test_foo.py",
vec![target("python", Some("pkg"), &["pytest"])],
),
],
&[],
);

assert_eq!(groups.len(), 2);
let mut commands: Vec<_> = groups.into_iter().map(|group| group.base_command).collect();
Expand All @@ -63,12 +66,41 @@ fn nested_package_relative_runner_args_are_stripped_when_grouping() {
);
dart.runner_args = vec!["test/user_test.dart".into()];
dart.config = Some("packages/app".into());
let groups =
grouped_execution_targets(&[selected("packages/app/test/user_test.dart", vec![dart])]);
let groups = grouped_execution_targets(
&[selected("packages/app/test/user_test.dart", vec![dart])],
&[],
);
assert_eq!(groups.len(), 1);
assert!(groups[0].runner_args.is_empty());
assert_eq!(
groups[0].test_files,
vec!["packages/app/test/user_test.dart".to_string()]
);
}

#[test]
fn path_prefixes_split_and_name_execution_targets() {
let groups = grouped_execution_targets(
&[
selected(
"swift-clients/core/Tests/A.swift",
vec![target("swift", None, &["swift", "test"])],
),
selected(
"swift-clients/ui/Tests/B.swift",
vec![target("swift", None, &["swift", "test"])],
),
],
&["swift-clients/core".into(), "swift-clients/ui".into()],
);
assert_eq!(groups.len(), 2);
let mut names: Vec<_> = groups.into_iter().map(|group| group.name).collect();
names.sort();
assert_eq!(
names,
vec![
Some("swift-clients/core".into()),
Some("swift-clients/ui".into())
]
);
}
1 change: 1 addition & 0 deletions crates/no-mistakes/src/tests/plan_resources_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ fn resource_plan_args(root: &Path, changed: PathBuf) -> PlanArgs {
format: None,
json: true,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/tests/prepared_plan/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fn framework_args(root: &Path, framework: TestFramework) -> PlanArgs {
format: None,
json: false,
include_comment: false,
include_glob: Vec::new(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/no-mistakes/src/tests/why.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ fn run_live_analysis(
format: None,
json: true,
include_comment: false,
include_glob: Vec::new(),
};

let plan = generate_plan(&plan_args)?;
Expand Down
11 changes: 9 additions & 2 deletions docs/cli/tests-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ returning an empty plan:
Node's `testsPlan()` rejects with the same stable code and message instead of
resolving to an empty plan.

JSON plans from the CLI keep snake_case keys. The Node `testsPlan()` /
`testsImpact()` APIs return camelCase only (`changedFiles`, `selectedTests`,
`executionTargets`, `fallbackTriggered`). `executionTargets` is the CI
contract: tests grouped by runner, config, project, and optional path-prefix
`name` (Swift packages). `--include-glob` / `includeGlob` keeps only selected
tests whose relative path matches.

JSON plans include `changed_files`, the sorted, deduplicated, root-relative
inventory prepared by that invocation. It is present even when no tests are
selected and retains deleted paths plus both sides of detected renames and
Expand All @@ -68,8 +75,8 @@ an in-root target keeps its lexical path in `changed_files`, while dependency
analysis follows the resolved target.

Key options: `--root`, `--config`, `--tsconfig`, `--environment`,
`--limit-percent`, `--limit-files`, `--global-config-fallback`, `--format`, and
`--json`.
`--limit-percent`, `--limit-files`, `--global-config-fallback`,
`--include-comment`, `--include-glob`, `--format`, and `--json`.

`--format explain` renders a deterministic, human-readable plan: the normalized
changed-file inventory (including files that selected no tests), selected test
Expand Down
4 changes: 4 additions & 0 deletions docs/node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,4 +414,8 @@ addon avoids UTF-16 string copies at the N-API boundary.
- Omit `tsconfig` to use automatic per-workspace resolution; pass it explicitly
only to force one config for debugging or compatibility.
- Use `analyzeProject()` when several reports share the same root/config.
Batch `testsPlan` with other reports in one `analyzeProject({ reports })`
call so they share the machine-wide lock. `testsPlan()` / `testsImpact()`
return camelCase `executionTargets` (optional `name` for path-prefix
groups) and accept `includeGlob`.
Comment thread
jonathanong marked this conversation as resolved.
Outdated
- Prefer structured API results over parsing human CLI output.
12 changes: 11 additions & 1 deletion packages/no-mistakes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,23 @@ const jsonApis = createJsonApis({
symbols: "symbolsJson",
});

async function analyzeProject(options) {
const result = await jsonApis.analyzeProject(options);
Comment thread
jonathanong marked this conversation as resolved.
Outdated
for (const report of result.reports || []) {
if (report.type === "testsPlan" || report.type === "testsImpact") {
report.result = planning.camelizeValue(report.result);
Comment thread
jonathanong marked this conversation as resolved.
Outdated
}
}
return result;
}

async function version() {
return native.version();
}

module.exports.createWorkflowTopologyIndex = createWorkflowTopologyIndex;
module.exports.version = version;
module.exports.analyzeProject = jsonApis.analyzeProject;
module.exports.analyzeProject = analyzeProject;
module.exports.callSites = jsonApis.callSites;
module.exports.check = jsonApis.check;
module.exports.resolveConfig = jsonApis.resolveConfig;
Expand Down
25 changes: 25 additions & 0 deletions packages/no-mistakes/planning.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ function createJsonApis(descriptors) {
);
}

function camelizeKey(key) {
return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}

function camelizeValue(value) {
if (Array.isArray(value)) return value.map(camelizeValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [camelizeKey(key), camelizeValue(nested)]),
);
}
return value;
}

async function testsComment(options) {
const input = Buffer.from(JSON.stringify(options || {}));
return String(await native.testsCommentMarkdown(input));
Expand Down Expand Up @@ -44,8 +58,19 @@ const jsonApis = createJsonApis({
testsWhy: "testsWhyJson",
});

async function testsPlan(options) {
return camelizeValue(await jsonApis.testsPlan(options));
Comment thread
jonathanong marked this conversation as resolved.
}

async function testsImpact(options) {
return camelizeValue(await jsonApis.testsImpact(options));
}

module.exports = {
camelizeValue,
testsComment,
testsGraphMermaid,
...jsonApis,
testsImpact,
testsPlan,
};
Loading
Loading