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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ the tests that reach them, across project boundaries.

runs `… exec mix test --partitions 2 test/a_test.exs test/b_test.exs`
with the tests selected in `//core`, so each shard takes its slice of the
selection; `vitest --shard=1/2` works the same way. Everything before the
selection; `vitest --shard=1/2` works the same way. A `mix test` shard
whose partition gets none of the selected files is skipped: partition 3
of two files is empty, and Mix fails on paths that match nothing.
Everything before the
final command is kept, which must be a plain command after an optional
`cd <dir> &&`. Test targets the requested target depends on in other
projects are narrowed too, each to its own project's selection, so a
Expand Down
14 changes: 12 additions & 2 deletions src/cli/affected_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,16 @@ impl<'a> RelatedPlanner<'a> {
&& def.command.contains("{files}")
&& def.capabilities.contains(&TargetCapability::FilesList);
let plugin = self.registry.find_by_name(&project.plugin_name);
// The plugin sees the environment a one-liner sets for the command,
// which the one-liner itself keeps.
let environment = runner
.delegated
.as_ref()
.map(|delegated| delegated.environment())
.unwrap_or_default();
let command = format!("{environment}{}", runner.command);
let attempt = |tests: &[RelatedTest]| {
plugin.and_then(|p| p.related_tests(&project.root, &runner.command, tests))
plugin.and_then(|p| p.related_tests(&project.root, &command, tests))
};
if !placeholder && attempt(&[]).is_none() {
return Ok(Narrowed::NotRunner);
Expand Down Expand Up @@ -480,7 +488,9 @@ impl<'a> RelatedPlanner<'a> {
let commands: Vec<String> = commands
.iter()
.map(|command| match &runner.delegated {
Some(delegated) => delegated.render(command),
Some(delegated) => {
delegated.render(command.strip_prefix(&environment).unwrap_or(command))
}
None => command.clone(),
})
.collect();
Expand Down
45 changes: 45 additions & 0 deletions src/plugins/related_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,22 @@ impl Delegated {
})
}

/// The environment assignments written before the test command
/// (`A=1 exec mix test` has `A=1`), each followed by a space.
pub(crate) fn environment(&self) -> String {
let segment = self
.head
.rsplit("&&")
.next()
.and_then(|rest| rest.rsplit(';').next())
.unwrap_or("");
segment
.split_whitespace()
.filter(|word| is_assignment(word))
.map(|word| format!("{word} "))
.collect()
}

/// The one-liner with its test command replaced.
pub(crate) fn render(&self, command: &str) -> String {
let script = format!("{}{command}", self.head);
Expand Down Expand Up @@ -321,9 +337,38 @@ pub(super) fn mix(
if keep.is_empty() {
return plan(Vec::new());
}
// `--partitions N` deals the files it is given out in turn, and
// partition K of fewer than K files gets none: Mix then fails, saying
// the paths match nothing.
if let Some(partition) = mix_partition(&argv, test) {
let given: BTreeSet<&String> = keep.iter().collect();
if given.len() < partition {
return plan(Vec::new());
}
}
plan(vec![argv.render(&listed, &keep)])
}

/// Which partition a `mix test --partitions N` command runs, when
/// `MIX_TEST_PARTITION` is written in front of it.
fn mix_partition(argv: &Argv, test: usize) -> Option<usize> {
let arguments = &argv.parts[test + 1..];
let total: usize = arguments.iter().enumerate().find_map(|(i, part)| {
match part.strip_prefix("--partitions") {
Some("") => arguments.get(i + 1)?.parse().ok(),
Some(rest) => rest.strip_prefix('=')?.parse().ok(),
None => None,
}
})?;
let partition: usize = argv.parts[..argv.program]
.iter()
.rev()
.find_map(|part| part.strip_prefix("MIX_TEST_PARTITION="))?
.parse()
.ok()?;
(total > 1 && (1..=total).contains(&partition)).then_some(partition)
}

const JS_RUNNERS: &[&str] = &["vitest", "jest", "mocha"];
const JS_LAUNCHERS: &[&str] = &[
"npx",
Expand Down
34 changes: 31 additions & 3 deletions tests/related.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,9 @@ fn shard_projects_run_their_slice_of_the_selected_tests() {
"Enum.sum(items)",
"Enum.sum(items) + 0",
);
// Both pricing functions changed, so two test files are selected. Mix
// deals them out, one to each partition.
ws.edit("shop/lib/shop/pricing.ex", "amount * 0.2", "amount * 0.25");
let plan = ws.plan("test-ci");
for shard in ["1", "2"] {
let commands = plan.commands(&format!("//shop-shard-{shard}:test-ci"));
Expand All @@ -1172,7 +1175,7 @@ fn shard_projects_run_their_slice_of_the_selected_tests() {
"-c".to_string(),
format!(
"cd ../shop && MIX_TEST_PARTITION={shard} exec mix test --warnings-as-errors \
--partitions 2 test/shop/cart_test.exs"
--partitions 2 test/shop/cart_test.exs test/shop/tax_test.exs"
),
]
);
Expand All @@ -1184,6 +1187,27 @@ fn shard_projects_run_their_slice_of_the_selected_tests() {
);
}

#[test]
fn a_shard_whose_partition_gets_no_selected_file_is_skipped() {
let ws = Workspace::new(&sharded());
ws.edit(
"shop/lib/shop/pricing.ex",
"Enum.sum(items)",
"Enum.sum(items) + 0",
);
// One file is selected. `mix test --partitions 2` gives it to the first
// partition; the second would fail, saying its paths match nothing.
let plan = ws.plan("test-ci");
assert_eq!(
plan.commands("//shop-shard-1:test-ci"),
["bash -c 'cd ../shop && MIX_TEST_PARTITION=1 exec mix test --warnings-as-errors --partitions 2 test/shop/cart_test.exs'"]
);
assert!(
!plan.runs().iter().any(|addr| addr.contains("shop-shard-2")),
"{plan:#?}"
);
}

#[test]
fn shard_projects_are_skipped_when_no_test_reaches_the_change() {
let ws = Workspace::new(&sharded());
Expand Down Expand Up @@ -1236,8 +1260,12 @@ fn shard_projects_run_in_full_when_their_own_files_change() {
assert!(plan
.notes("//shop-shard-1:test-ci")
.contains("shop-shard-1/aster.toml changed; running in full"));
// The other shard still narrows.
assert_eq!(plan.commands("//shop-shard-2:test-ci").len(), 1);
// The other shard still narrows: to nothing, as the one selected file
// is not in its partition.
assert!(
!plan.runs().iter().any(|addr| addr.contains("shop-shard-2")),
"{plan:#?}"
);
}

#[test]
Expand Down
Loading