Skip to content

Commit

Permalink
Scope to failures (#227)
Browse files Browse the repository at this point in the history
# In short

If you ran a test job, and there's a failure, hit <kbd>f</kbd> to have the job "scoped" to the failures, ie not executing other tests.

If you want to go back to all tests, hit <kbd>esc</kbd>.

Fix #214 

# Details

`cargo test` doesn't support passing several test keys, so scoping only takes the first failure.

If you're running another test command, for example `cargo nextest run`, then all failures are part of the scope.

It's possible to start bacon in scoped mode: `bacon test(mymodule::some_fun)`. Hitting <kbd>esc</kbd> will bring you to the unscoped tests.

If you want to define a different binding (maybe you're already using the <kbd>f</kbd> key), you can refer to the `scope-to-failures` internal. For example:

```
[keybindings]
alt-f = "scope-to-failures"
```
  • Loading branch information
Canop authored Oct 6, 2024
1 parent 3168579 commit ae7e675
Show file tree
Hide file tree
Showing 23 changed files with 427 additions and 193 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### next
- support for nextest output & default nextest job, bound by default to the 'n' key - Fix #196
- hit 'f' to have tests "scoped" to the failures - Fix #214
- new `exports` structure in configuration. New `analysis` export bound by default to `ctrl-e`. The old syntax defining locations export is still supported but won't appear in documentations anymore.
- recognize panic location in test - Fix #208
- lines to ignore can be specified as a set of regular expressions in a `ignored_lines` field either in the job or at the top of the prefs or bacon.toml - Fix #223
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ This will run against all targets like `check-all` does.

bacon test

or `bacon nextest` if you're a nextest user.

![bacon test](doc/test.png)


When there's a failure, hit <kbd>f</kbd> to restrict the job to the failing test.
Hit <kbd>esc</kbd> to get back to all tests.

## define your own jobs

First create a `bacon.toml` file by running
Expand Down
7 changes: 3 additions & 4 deletions defaults/default-prefs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,9 @@ path = "bacon-analysis.json"
# ctrl-q = "quit"
# q = "quit"
# F5 = "rerun"
# s = "toggle-summary"
# w = "toggle-wrap"
# b = "toggle-backtrace"
# f = "toggle-backtrace(full)"
# alt-s = "toggle-summary"
# alt-w = "toggle-wrap"
# alt-b = "toggle-backtrace"
# Home = "scroll-to-top"
# End = "scroll-to-bottom"
# Up = "scroll-lines(-1)"
Expand Down
6 changes: 4 additions & 2 deletions src/analysis/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,15 @@ impl Analyzer {
_ => {}
}
}
for (key, failure) in failures.drain() {
for (key, failure) in &failures {
// if we know of a failure but there was no content, we add some
if failure.has_title {
continue;
}
fails.push(Line {
item_idx: 0, // will be filled later
line_type: LineType::Title(Kind::TestFail),
content: TLine::failed(&key),
content: TLine::failed(key),
});
fails.push(Line {
item_idx: 0,
Expand All @@ -181,11 +181,13 @@ impl Analyzer {
let mut stats = Stats::from(&lines);
stats.passed_tests = passed_tests;
debug!("stats: {:#?}", &stats);
let failure_keys = failures.keys().cloned().collect();
let report = Report {
lines,
stats,
suggest_backtrace,
output: Default::default(),
failure_keys,
};
Ok(report)
}
Expand Down
10 changes: 9 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ pub fn run(
event_source.unblock(false);
}
}
info!("action: {action:?}");
if let Some(action) = action.take() {
debug!("requested action: {action:?}");
match action {
Expand Down Expand Up @@ -192,6 +191,15 @@ pub fn run(
task_executor.die();
task_executor = state.start_computation(&mut executor)?;
}
Internal::ScopeToFailures => {
if let Some(scope) = state.failures_scope() {
info!("scoping to failures: {scope:#?}");
next_job = Some(JobRef::Scope(scope));
break;
} else {
warn!("no available failures scope");
}
}
Internal::ToggleRawOutput => {
state.toggle_raw_output();
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub fn run() -> anyhow::Result<()> {
let mut result = Ok(());
#[allow(clippy::while_let_loop)]
loop {
let (job_name, job) = match job_stack.pick_job(&next_job) {
let (concrete_job_ref, job) = match job_stack.pick_job(&next_job) {
Err(e) => {
result = Err(e);
break;
Expand All @@ -139,7 +139,7 @@ pub fn run() -> anyhow::Result<()> {
break;
}
};
let r = Mission::new(&location, job_name.to_string(), job, &settings)
let r = Mission::new(&location, concrete_job_ref, job, &settings)
.and_then(|mission| app::run(&mut w, mission, &event_source));
match r {
Ok(Some(job_ref)) => {
Expand Down
10 changes: 10 additions & 0 deletions src/help_line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub struct HelpLine {
close_help: Option<String>,
pause: Option<String>,
unpause: Option<String>,
scope: Option<String>,
}

impl HelpLine {
Expand Down Expand Up @@ -48,6 +49,9 @@ impl HelpLine {
.shortest_internal_key(Internal::Unpause)
.or(kb.shortest_internal_key(Internal::TogglePause))
.map(|k| format!("*{k}* to unpause"));
let scope = kb
.shortest_internal_key(Internal::ScopeToFailures)
.map(|k| format!("*{k}* to scope to failures"));
Self {
quit,
toggle_summary,
Expand All @@ -58,6 +62,7 @@ impl HelpLine {
close_help,
pause,
unpause,
scope,
}
}
pub fn markdown(
Expand All @@ -70,6 +75,11 @@ impl HelpLine {
parts.push(s);
}
} else {
if state.can_be_scoped() {
if let Some(s) = &self.scope {
parts.push(s);
}
}
if state.auto_refresh.is_paused() {
if let Some(s) = &self.unpause {
parts.push(s);
Expand Down
3 changes: 3 additions & 0 deletions src/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub enum Internal {
Quit,
Refresh, // clear and rerun
ReRun,
ScopeToFailures,
Scroll(ScrollCommand),
ToggleBacktrace(&'static str),
ToggleRawOutput,
Expand All @@ -33,6 +34,7 @@ impl fmt::Display for Internal {
Self::Quit => write!(f, "quit"),
Self::Refresh => write!(f, "clear then run current job again"),
Self::ReRun => write!(f, "run current job again"),
Self::ScopeToFailures => write!(f, "scope to failures"),
Self::Scroll(scroll_command) => scroll_command.fmt(f),
Self::ToggleBacktrace(level) => write!(f, "toggle backtrace ({level})"),
Self::ToggleRawOutput => write!(f, "toggle raw output"),
Expand All @@ -57,6 +59,7 @@ impl std::str::FromStr for Internal {
"quit" => Ok(Self::Quit),
"refresh" => Ok(Self::Refresh),
"rerun" => Ok(Self::ReRun),
"scope-to-failures" => Ok(Self::ScopeToFailures),
"toggle-raw-output" => Ok(Self::ToggleRawOutput),
"toggle-backtrace" => Ok(Self::ToggleBacktrace("1")),
"toggle-backtrace(1)" => Ok(Self::ToggleBacktrace("1")),
Expand Down
150 changes: 133 additions & 17 deletions src/job_ref.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use {
crate::*,
lazy_regex::*,
serde::{
Deserialize,
Deserializer,
Expand All @@ -16,25 +18,66 @@ pub enum JobRef {
Initial,
Previous,
Concrete(ConcreteJobRef),
Scope(Scope),
}

impl JobRef {
pub fn from_job_name<S: Into<String>>(s: S) -> Self {
Self::Concrete(ConcreteJobRef::Name(s.into()))
Self::Concrete(ConcreteJobRef::from_job_name(s))
}
}

/// A "concrete" job ref is one which can be used from the start, without
/// referring to the job stack
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ConcreteJobRef {
pub struct ConcreteJobRef {
pub name_or_alias: NameOrAlias,
pub scope: Scope,
}

impl ConcreteJobRef {
pub fn from_job_name<S: Into<String>>(s: S) -> Self {
Self {
name_or_alias: NameOrAlias::Name(s.into()),
scope: Default::default(),
}
}
pub fn badge_label(&self) -> String {
let mut s = String::new();
match &self.name_or_alias {
NameOrAlias::Name(name) => {
s.push_str(name);
}
NameOrAlias::Alias(alias) => {
s.push_str(alias);
}
}
if self.scope.has_tests() {
s.push_str(" (scoped)");
}
s
}
pub fn with_scope(
mut self,
scope: Scope,
) -> Self {
self.scope = scope;
self
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum NameOrAlias {
Name(String),
Alias(String),
}

impl Default for ConcreteJobRef {
fn default() -> Self {
Self::Name("check".to_string())
Self {
name_or_alias: NameOrAlias::Name("check".to_string()),
scope: Default::default(),
}
}
}

Expand All @@ -43,10 +86,14 @@ impl fmt::Display for ConcreteJobRef {
&self,
f: &mut fmt::Formatter,
) -> fmt::Result {
match self {
Self::Alias(alias) => write!(f, "alias:{alias}"),
Self::Name(name) => write!(f, "{name}"),
match &self.name_or_alias {
NameOrAlias::Alias(alias) => write!(f, "alias:{alias}")?,
NameOrAlias::Name(name) => write!(f, "{name}")?,
}
if self.scope.has_tests() {
write!(f, "({})", self.scope.tests.join(","))?;
}
Ok(())
}
}

Expand All @@ -72,10 +119,27 @@ impl<'de> Deserialize<'de> for ConcreteJobRef {

impl From<&str> for ConcreteJobRef {
fn from(str_entry: &str) -> Self {
if let Some(alias) = str_entry.strip_prefix("alias:") {
ConcreteJobRef::Alias(alias.to_string())
let Some((_, alias_prefix, name_or_alias, scope)) =
regex_captures!(r"^(alias:)?([^\(\)]+)(?:\(([^\)]+)\))?$", str_entry,)
else {
warn!("unexpected job ref: {:?}", str_entry);
return Self::from_job_name(str_entry.to_string());
};
let name_or_alias = if alias_prefix.is_empty() {
NameOrAlias::Name(name_or_alias.to_string())
} else {
ConcreteJobRef::Name(str_entry.to_string())
NameOrAlias::Alias(name_or_alias.to_string())
};
let scope = Scope {
tests: scope
.split(',')
.filter(|t| !t.trim().is_empty())
.map(|s| s.to_string())
.collect(),
};
Self {
name_or_alias,
scope,
}
}
}
Expand All @@ -89,18 +153,70 @@ impl fmt::Display for JobRef {
Self::Default => write!(f, "default"),
Self::Initial => write!(f, "initial"),
Self::Previous => write!(f, "previous"),
Self::Concrete(concrete) => concrete.fmt(f),
Self::Scope(Scope { tests }) => write!(f, "scope:{}", tests.join(",")),
Self::Concrete(concrete) => write!(f, "{}", concrete),
}
}
}

impl From<&str> for JobRef {
fn from(name: &str) -> Self {
match name {
"default" => Self::Default,
"initial" => Self::Initial,
"previous" => Self::Previous,
_ => Self::Concrete(ConcreteJobRef::from(name)),
}
fn from(s: &str) -> Self {
regex_switch!(s,
"^default$"i => Self::Default,
"^default$"i => Self::Default,
"^initial$"i => Self::Initial,
"^previous$"i => Self::Previous,
"^scope:(?<tests>.+)$" => Self::Scope(Scope {
tests: tests
.split(',')
.filter(|t| !t.trim().is_empty())
.map(|s| s.to_string())
.collect(),
}),
)
.unwrap_or_else(|| Self::Concrete(ConcreteJobRef::from(s)))
}
}

#[test]
fn test_job_ref_string_round_trip() {
let job_refs = vec![
JobRef::Default,
JobRef::Initial,
JobRef::Previous,
JobRef::Concrete(ConcreteJobRef {
name_or_alias: NameOrAlias::Name("run".to_string()),
scope: Scope::default(),
}),
JobRef::Concrete(ConcreteJobRef {
name_or_alias: NameOrAlias::Name("nextest".to_string()),
scope: Scope {
tests: vec!["first::test".to_string(), "second_test".to_string()],
},
}),
JobRef::Concrete(ConcreteJobRef {
name_or_alias: NameOrAlias::Alias("my-check".to_string()),
scope: Scope::default(),
}),
JobRef::Concrete(ConcreteJobRef {
name_or_alias: NameOrAlias::Alias("my-test".to_string()),
scope: Scope {
tests: vec!["abc".to_string()],
},
}),
JobRef::Concrete(ConcreteJobRef {
name_or_alias: NameOrAlias::Name("nextest".to_string()),
scope: Scope {
tests: vec!["abc".to_string()],
},
}),
JobRef::Scope(Scope {
tests: vec!["first::test".to_string(), "second_test".to_string()],
}),
];
for job_ref in job_refs {
let s = job_ref.to_string();
let job_ref2 = JobRef::from(s.as_str());
assert_eq!(job_ref, job_ref2);
}
}
Loading

0 comments on commit ae7e675

Please sign in to comment.