use std::io::IsTerminal;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use alba_core::{BeamId, Project, SourceMap};
use alba_engine::{
CacheOptions, EngineError, Executors, RunEvent, RunOptions, RunSummary, Selection,
SessionError, WatchExit,
};
use alba_executors::{DockerExecutor, EmbeddedShellExecutor, SystemShellExecutor};
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
use tokio_util::sync::CancellationToken;
use crate::args::{LogFormat, OutputStyle, RunFlags};
use crate::exit::{EXIT_ALBA_ERROR, EXIT_INTERRUPTED};
use crate::render::{GroupedRenderer, InterleavedRenderer, JsonRenderer, LineSink, Renderer};
pub fn run(
project: &Project,
sources: &SourceMap,
beamfile: &Path,
selection: &Selection,
params: Vec<String>,
flags: &RunFlags,
) -> i32 {
let interactive = match ui_enabled(flags, std::io::stdout().is_terminal()) {
UiDecision::Tui => true,
UiDecision::Headless => false,
UiDecision::RefusedNoTty => {
LineSink::stderr().line("--ui needs a terminal; stdout is not one");
return EXIT_ALBA_ERROR;
}
};
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
LineSink::stderr().line(&format!("cannot start the async runtime: {error}"));
return EXIT_ALBA_ERROR;
}
};
if interactive {
runtime.block_on(tui_execute(
project, sources, beamfile, selection, params, flags,
))
} else if flags.watch {
runtime.block_on(watch_execute(
project, sources, beamfile, selection, params, flags,
))
} else {
runtime.block_on(execute(
project, sources, beamfile, selection, params, flags,
))
}
}
fn executors(beamfile: &Path) -> Executors {
Executors {
embedded: Arc::new(EmbeddedShellExecutor),
system: Arc::new(SystemShellExecutor),
docker: Arc::new(DockerExecutor::new(alba_engine::beamfile_dir(beamfile))),
}
}
#[derive(Debug, PartialEq, Eq)]
enum UiDecision {
Tui,
Headless,
RefusedNoTty,
}
fn ui_enabled(flags: &RunFlags, stdout_is_tty: bool) -> UiDecision {
if flags.no_ui || flags.log_format == LogFormat::Json || flags.output.is_some() {
return UiDecision::Headless;
}
match (flags.ui, stdout_is_tty) {
(_, true) => UiDecision::Tui,
(true, false) => UiDecision::RefusedNoTty,
(false, false) => UiDecision::Headless,
}
}
async fn execute(
project: &Project,
sources: &SourceMap,
beamfile: &Path,
selection: &Selection,
params: Vec<String>,
flags: &RunFlags,
) -> i32 {
let options = RunOptions {
jobs: jobs(flags.jobs),
keep_going: flags.keep_going,
params,
cache: Some(CacheOptions {
dir: cache_dir(beamfile),
force: flags.force,
}),
extra_env: Vec::new(),
};
let root = alba_engine::beamfile_dir(beamfile);
let targets = match alba_engine::select(project, sources, &root, selection) {
Ok(targets) => targets,
Err(error) => {
LineSink::stderr().line(render_engine_error(&error, sources).trim_end());
return EXIT_ALBA_ERROR;
}
};
let (events, incoming) = unbounded_channel();
let cancel = CancellationToken::new();
tokio::spawn(watch_interrupts(cancel.clone()));
let consumer = tokio::spawn(consume(incoming, renderer(flags), cancel.clone()));
let result = alba_engine::run(
project,
&targets,
options,
executors(beamfile),
events,
cancel.clone(),
)
.await;
let mut err = LineSink::stderr();
let renderer_panicked = consumer.await.is_err();
if renderer_panicked {
err.line("the output renderer panicked; this run's report is incomplete");
}
let code = match result {
Ok(summary) => run_exit_code(&summary, &cancel),
Err(error) => {
err.line(render_engine_error(&error, sources).trim_end());
EXIT_ALBA_ERROR
}
};
if renderer_panicked {
EXIT_ALBA_ERROR
} else {
code
}
}
async fn watch_execute(
project: &Project,
sources: &SourceMap,
beamfile: &Path,
selection: &Selection,
params: Vec<String>,
flags: &RunFlags,
) -> i32 {
let mut err = LineSink::stderr();
let root = alba_engine::beamfile_dir(beamfile);
if let Err(error) = alba_engine::select(project, sources, &root, selection) {
err.line(render_engine_error(&error, sources).trim_end());
return EXIT_ALBA_ERROR;
}
if let Some(target) = selection.watched_target()
&& let Ok(subgraph) = alba_core::execution_subgraph(project, target)
&& let Some(warning) = no_inputs_warning(project, target, &subgraph)
{
err.line(&warning);
}
let watcher = match alba_engine::NotifyWatcher::new(&watch_roots(beamfile, sources, project)) {
Ok(watcher) => watcher,
Err(error) => {
err.line(&format!("cannot start the file watcher: {error}"));
return EXIT_ALBA_ERROR;
}
};
let options = RunOptions {
jobs: jobs(flags.jobs),
keep_going: flags.keep_going,
params,
cache: Some(CacheOptions {
dir: cache_dir(beamfile),
force: flags.force,
}),
extra_env: Vec::new(),
};
let (events, incoming) = unbounded_channel();
let cancel = CancellationToken::new();
tokio::spawn(watch_interrupts(cancel.clone()));
let consumer = tokio::spawn(consume(incoming, watch_renderer(flags), cancel.clone()));
let exit = alba_engine::watch(
beamfile,
project.clone(),
sources.clone(),
selection.clone(),
options,
executors(beamfile),
events,
cancel,
Box::new(watcher),
&mut |error| match error {
SessionError::Load(load) => crate::render_load_error(load),
SessionError::Run { error, sources } => render_engine_error(error, sources),
},
)
.await;
if consumer.await.is_err() {
err.line("the output renderer panicked; this session's report is incomplete");
return EXIT_ALBA_ERROR;
}
match exit {
WatchExit::Interrupted => 0,
WatchExit::WatcherClosed => {
err.line("the file watcher stopped; ending the session");
EXIT_ALBA_ERROR
}
}
}
async fn tui_execute(
project: &Project,
sources: &SourceMap,
beamfile: &Path,
selection: &Selection,
params: Vec<String>,
flags: &RunFlags,
) -> i32 {
let mut err = LineSink::stderr();
let root = alba_engine::beamfile_dir(beamfile);
if let Err(error) = alba_engine::select(project, sources, &root, selection) {
err.line(render_engine_error(&error, sources).trim_end());
return EXIT_ALBA_ERROR;
}
let watcher = match alba_engine::NotifyWatcher::new(&watch_roots(beamfile, sources, project)) {
Ok(watcher) => watcher,
Err(error) => {
err.line(&format!("cannot start the file watcher: {error}"));
return EXIT_ALBA_ERROR;
}
};
let colour = crate::color_enabled();
let options = RunOptions {
jobs: jobs(flags.jobs),
keep_going: flags.keep_going,
params,
cache: Some(CacheOptions {
dir: cache_dir(beamfile),
force: flags.force,
}),
extra_env: if colour {
vec![
("FORCE_COLOR".to_string(), "1".to_string()),
("CLICOLOR_FORCE".to_string(), "1".to_string()),
]
} else {
Vec::new()
},
};
let (events, incoming) = unbounded_channel();
let (commands, command_receiver) = unbounded_channel();
let cancel = CancellationToken::new();
let session = tokio::spawn({
let beamfile = beamfile.to_path_buf();
let project = project.clone();
let sources = sources.clone();
let selection = selection.clone();
let watch = flags.watch;
async move {
alba_engine::session(
&beamfile,
project,
sources,
selection,
options,
executors(&beamfile),
events,
Some(command_receiver),
watch,
cancel,
Box::new(watcher),
&mut |error| match error {
SessionError::Load(load) => crate::render_load_error(load),
SessionError::Run { error, sources } => render_engine_error(error, sources),
},
)
.await
}
});
let outcome = alba_tui::run(
incoming,
commands,
alba_tui::TuiOptions {
target: selection_label(selection),
watch: flags.watch,
colour,
},
)
.await;
let failure = session_failure(session.await);
if let Some(message) = failure {
err.line(message);
}
let outcome = match outcome {
Ok(outcome) => outcome,
Err(error) => {
err.line(&format!("the interface failed: {error}"));
return EXIT_ALBA_ERROR;
}
};
let affected_by = match selection {
Selection::Affected { reference, .. } => Some(reference.as_str()),
Selection::Beam(_) => None,
};
replay(&mut err, &outcome, affected_by, colour);
match failure {
Some(_) => EXIT_ALBA_ERROR,
None => outcome.last_run_code.unwrap_or(EXIT_INTERRUPTED),
}
}
fn selection_label(selection: &Selection) -> String {
match selection {
Selection::Beam(id)
| Selection::Affected {
within: Some(id), ..
} => id.0.clone(),
Selection::Affected {
reference,
within: None,
} => format!("affected by {reference}"),
}
}
fn session_failure(exit: Result<WatchExit, tokio::task::JoinError>) -> Option<&'static str> {
match exit {
Ok(WatchExit::Interrupted) => None,
Ok(WatchExit::WatcherClosed) => Some("the file watcher stopped; ending the session"),
Err(_) => Some("the session ended unexpectedly; this session's report is incomplete"),
}
}
fn replay<W: std::io::Write>(
err: &mut LineSink<W>,
outcome: &alba_tui::TuiOutcome,
affected_by: Option<&str>,
colour: bool,
) {
if let Some(summary) = &outcome.last_summary {
crate::render::print_summary(err, summary, affected_by);
}
for (beam, lines) in &outcome.failed_logs {
err.line(&format!("\u{2500}\u{2500} {beam} \u{2500}\u{2500}"));
if lines.len() >= alba_tui::logs::MAX_LINES {
err.line(&format!(
"(the interface keeps at most {} lines per beam; anything earlier is not replayed)",
alba_tui::logs::MAX_LINES
));
}
for line in lines {
err.line(if colour { &line.raw } else { &line.text });
}
}
}
fn no_inputs_warning(project: &Project, target: &BeamId, subgraph: &[BeamId]) -> Option<String> {
let declares_inputs = project
.beams
.iter()
.filter(|beam| subgraph.contains(&beam.id))
.any(|beam| !beam.inputs.is_empty());
(!declares_inputs).then(|| {
format!(
"warning: no beam in `{}`'s graph declares inputs; watching the Beamfile only",
target.0
)
})
}
fn watch_roots(beamfile: &Path, sources: &SourceMap, project: &Project) -> Vec<PathBuf> {
let mut roots = roots_of(beamfile, sources.paths());
let input_dirs = project.beams.iter().flat_map(|beam| {
beam.inputs
.iter()
.map(|pattern| alba_core::pattern_dir(&beam.dir, pattern))
});
for dir in input_dirs {
if dir.is_dir() && !roots.iter().any(|root| dir.starts_with(root)) {
roots.push(dir);
}
}
roots
}
fn roots_of<'a>(beamfile: &Path, loaded: impl Iterator<Item = &'a Path>) -> Vec<PathBuf> {
let root = alba_engine::beamfile_dir(beamfile);
let mut roots = vec![root.clone()];
for path in loaded {
let dir = alba_engine::beamfile_dir(path);
if !dir.starts_with(&root) && !roots.contains(&dir) {
roots.push(dir);
}
}
roots
}
fn watch_renderer(flags: &RunFlags) -> Box<dyn Renderer> {
let clear = std::io::stdout().is_terminal();
match flags.log_format {
LogFormat::Json => Box::new(JsonRenderer::new()),
LogFormat::Text => match flags.output.unwrap_or_else(default_output) {
OutputStyle::Interleaved => {
Box::new(InterleavedRenderer::new(crate::color_enabled(), clear))
}
OutputStyle::Grouped => Box::new(GroupedRenderer::new(clear)),
},
}
}
fn run_exit_code(summary: &RunSummary, cancel: &CancellationToken) -> i32 {
if cancel.is_cancelled() {
EXIT_INTERRUPTED
} else {
summary.exit_code()
}
}
async fn consume(
mut incoming: UnboundedReceiver<RunEvent>,
mut renderer: Box<dyn Renderer>,
cancel: CancellationToken,
) {
let mut err = LineSink::stderr();
let mut announced = false;
loop {
tokio::select! {
biased;
() = cancel.cancelled(), if !announced => {
announced = true;
err.line("cancelling...");
}
event = incoming.recv() => match event {
Some(event) => renderer.handle(&event),
None => break,
},
}
}
}
async fn watch_interrupts(cancel: CancellationToken) {
if tokio::signal::ctrl_c().await.is_err() {
return;
}
cancel.cancel();
if tokio::signal::ctrl_c().await.is_err() {
return;
}
LineSink::stderr().line("aborting");
std::process::exit(EXIT_INTERRUPTED);
}
pub(crate) fn cache_dir(beamfile: &Path) -> std::path::PathBuf {
beamfile
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.join(".alba")
.join("cache")
}
fn jobs(requested: Option<u64>) -> usize {
resolve_jobs(requested, std::thread::available_parallelism().ok())
}
fn resolve_jobs(requested: Option<u64>, available: Option<NonZeroUsize>) -> usize {
match requested {
Some(requested) => usize::try_from(requested).unwrap_or(usize::MAX),
None => available.map_or(1, NonZeroUsize::get),
}
}
fn renderer(flags: &RunFlags) -> Box<dyn Renderer> {
match flags.log_format {
LogFormat::Json => Box::new(JsonRenderer::new()),
LogFormat::Text => match flags.output.unwrap_or_else(default_output) {
OutputStyle::Interleaved => {
Box::new(InterleavedRenderer::new(crate::color_enabled(), false))
}
OutputStyle::Grouped => Box::new(GroupedRenderer::new(false)),
},
}
}
fn default_output() -> OutputStyle {
if std::io::stdout().is_terminal() {
OutputStyle::Interleaved
} else {
OutputStyle::Grouped
}
}
fn render_engine_error(error: &EngineError, sources: &SourceMap) -> String {
match error {
EngineError::Core(error) => crate::render_core_error(error, sources),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alba_core::BeamId;
fn eight() -> Option<NonZeroUsize> {
NonZeroUsize::new(8)
}
fn beam(id: &str, inputs: &[&str]) -> alba_core::Beam {
alba_core::Beam {
id: BeamId(id.to_string()),
description: None,
needs: Vec::new(),
params: Vec::new(),
inputs: inputs.iter().map(|input| (*input).to_string()).collect(),
outputs: Vec::new(),
run: Vec::new(),
env: Vec::new(),
cwd: None,
executor: alba_core::ExecutorKind::Shell,
allow_failure: false,
dir: PathBuf::from("."),
span: alba_syntax::Span::new(0, 0),
source: alba_core::SourceId(0),
scope: alba_core::Scope::empty(),
}
}
fn project(beams: Vec<alba_core::Beam>) -> Project {
Project {
beams,
default: None,
hooks: Vec::new(),
}
}
fn ids(ids: &[&str]) -> Vec<BeamId> {
ids.iter().map(|id| BeamId((*id).to_string())).collect()
}
#[test]
fn a_subgraph_without_inputs_is_warned_about() {
let project = project(vec![beam("build", &[])]);
let warning = no_inputs_warning(&project, &BeamId("build".to_string()), &ids(&["build"]));
let warning = warning.expect("a graph with no inputs must be warned about");
assert!(warning.contains("build"), "got: {warning}");
}
#[test]
fn inputs_on_a_dependency_are_enough_to_stay_silent() {
let project = project(vec![beam("build", &[]), beam("compile", &["src/**"])]);
assert_eq!(
no_inputs_warning(
&project,
&BeamId("build".to_string()),
&ids(&["build", "compile"])
),
None
);
}
#[test]
fn inputs_outside_the_subgraph_do_not_count() {
let project = project(vec![beam("build", &[]), beam("docs", &["docs/**"])]);
assert!(
no_inputs_warning(&project, &BeamId("build".to_string()), &ids(&["build"])).is_some()
);
}
#[test]
fn a_bare_beamfile_watches_the_current_directory() {
let bare = Path::new("Beamfile");
let roots = roots_of(bare, [bare].into_iter());
let here = std::path::absolute(".").unwrap();
assert_eq!(roots, vec![here]);
}
#[test]
fn beamfiles_under_the_root_add_no_roots() {
let root = std::path::absolute("project").unwrap();
let beamfile = root.join("Beamfile");
let nested = root.join("modules/api/Beamfile");
let roots = roots_of(
&beamfile,
[beamfile.as_path(), nested.as_path()].into_iter(),
);
assert_eq!(roots, vec![root]);
}
#[test]
fn a_beamfile_outside_the_root_adds_its_directory_once() {
let root = std::path::absolute("project").unwrap();
let beamfile = root.join("Beamfile");
let sibling = std::path::absolute("shared").unwrap();
let (first, second) = (sibling.join("A.beam"), sibling.join("B.beam"));
let roots = roots_of(
&beamfile,
[beamfile.as_path(), first.as_path(), second.as_path()].into_iter(),
);
assert_eq!(roots, vec![root, sibling]);
}
#[test]
fn an_input_above_the_root_adds_the_directory_it_matches_under() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let shared = dir.path().join("shared");
std::fs::create_dir_all(root.join("contexts/core")).unwrap();
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(
root.join("Beamfile"),
"import \"contexts/core/Beamfile\" as core\n\
beam lint { inputs [\"../shared/*.yaml\", \"../missing/*.yaml\"] run \"x\" }\n",
)
.unwrap();
std::fs::write(
root.join("contexts/core/Beamfile"),
"beam test { inputs [\"../../Dockerfile\"] run \"x\" }\n",
)
.unwrap();
let beamfile = root.join("Beamfile");
let (project, sources) = alba_core::load_project(&beamfile).unwrap();
assert_eq!(
watch_roots(&beamfile, &sources, &project),
vec![root, shared]
);
}
#[test]
fn an_explicit_jobs_value_wins_over_the_platform_default() {
assert_eq!(resolve_jobs(Some(3), eight()), 3);
}
#[test]
fn an_omitted_jobs_value_follows_the_platform() {
assert_eq!(resolve_jobs(None, eight()), 8);
}
#[test]
fn an_unknown_platform_parallelism_falls_back_to_one() {
assert_eq!(resolve_jobs(None, None), 1);
}
#[test]
fn the_tui_is_the_default_on_a_terminal_only() {
let flags = RunFlags::default();
assert_eq!(ui_enabled(&flags, true), UiDecision::Tui);
assert_eq!(ui_enabled(&flags, false), UiDecision::Headless);
}
#[test]
fn asking_for_a_text_layout_or_json_means_headless() {
let json = RunFlags {
log_format: LogFormat::Json,
..RunFlags::default()
};
let text = RunFlags {
output: Some(OutputStyle::Grouped),
..RunFlags::default()
};
let off = RunFlags {
no_ui: true,
..RunFlags::default()
};
for flags in [json, text, off] {
assert_eq!(ui_enabled(&flags, true), UiDecision::Headless);
}
}
#[test]
fn forcing_the_ui_off_a_terminal_is_refused_not_garbled() {
let flags = RunFlags {
ui: true,
..RunFlags::default()
};
assert_eq!(ui_enabled(&flags, false), UiDecision::RefusedNoTty);
assert_eq!(ui_enabled(&flags, true), UiDecision::Tui);
}
#[test]
fn turning_the_ui_off_wins_over_forcing_it_on() {
let flags = RunFlags {
ui: true,
no_ui: true,
..RunFlags::default()
};
assert_eq!(ui_enabled(&flags, true), UiDecision::Headless);
assert_eq!(ui_enabled(&flags, false), UiDecision::Headless);
}
fn replayed(outcome: &alba_tui::TuiOutcome, affected_by: Option<&str>) -> Vec<String> {
replayed_with(outcome, affected_by, false)
}
fn replayed_with(
outcome: &alba_tui::TuiOutcome,
affected_by: Option<&str>,
colour: bool,
) -> Vec<String> {
let mut buffer: Vec<u8> = Vec::new();
{
let mut sink = LineSink::new(&mut buffer);
replay(&mut sink, outcome, affected_by, colour);
}
String::from_utf8(buffer)
.expect("the replay writes UTF-8")
.lines()
.map(str::to_string)
.collect()
}
fn plain_lines(lines: Vec<String>) -> Vec<alba_tui::ReplayLine> {
lines
.into_iter()
.map(|line| alba_tui::ReplayLine {
raw: line.clone(),
text: line,
})
.collect()
}
fn outcome(
summary: Option<RunSummary>,
failed_logs: Vec<(String, Vec<alba_tui::ReplayLine>)>,
) -> alba_tui::TuiOutcome {
alba_tui::TuiOutcome {
last_run_code: summary.as_ref().map(RunSummary::exit_code),
last_summary: summary,
failed_logs,
}
}
#[test]
fn the_replay_of_a_green_run_is_its_summary() {
let summary = RunSummary {
succeeded: vec![BeamId("ok".to_string())],
..RunSummary::default()
};
let lines = replayed(&outcome(Some(summary), Vec::new()), None);
assert_eq!(lines.len(), 1, "got: {lines:?}");
assert!(
lines[0].contains("1 succeeded"),
"the summary the text renderers print, got: {lines:?}"
);
}
#[test]
fn the_replay_of_a_failing_beam_carries_its_buffered_output() {
let summary = RunSummary {
failed: vec![BeamId("bad".to_string())],
..RunSummary::default()
};
let logs = vec![(
"bad".to_string(),
plain_lines(vec!["boom 1".to_string(), "boom 2".to_string()]),
)];
let lines = replayed(&outcome(Some(summary), logs), None);
assert!(lines[0].contains("1 failed"), "got: {lines:?}");
assert_eq!(
&lines[1..],
["\u{2500}\u{2500} bad \u{2500}\u{2500}", "boom 1", "boom 2"],
"the beam's header, then every line it printed"
);
}
#[test]
fn a_truncated_beams_replay_discloses_what_is_missing() {
let summary = RunSummary {
failed: vec![BeamId("chatty".to_string())],
..RunSummary::default()
};
let logs = vec![(
"chatty".to_string(),
plain_lines(
(0..alba_tui::logs::MAX_LINES)
.map(|index| format!("line {index}"))
.collect::<Vec<_>>(),
),
)];
let lines = replayed(&outcome(Some(summary), logs), None);
assert_eq!(lines[1], "\u{2500}\u{2500} chatty \u{2500}\u{2500}");
assert!(
lines[2].contains(&format!("at most {} lines", alba_tui::logs::MAX_LINES)),
"the disclosure comes before the lines themselves, got: {:?}",
lines[2]
);
assert_eq!(lines[3], "line 0");
assert_eq!(lines.len(), 3 + alba_tui::logs::MAX_LINES);
}
#[test]
fn a_session_with_no_finished_run_replays_nothing() {
assert!(replayed(&outcome(None, Vec::new()), None).is_empty());
}
#[test]
fn the_replay_of_an_empty_affected_run_says_nothing_was_affected() {
let lines = replayed(
&outcome(Some(RunSummary::default()), Vec::new()),
Some("HEAD"),
);
assert_eq!(lines, ["\u{2713} nothing affected by HEAD"]);
}
#[test]
fn the_replay_prints_raw_lines_with_colour_and_plain_lines_without() {
let summary = RunSummary {
failed: vec![BeamId("red".to_string())],
..RunSummary::default()
};
let line = alba_tui::ReplayLine {
raw: "\u{1b}[31mred\u{1b}[0m".to_string(),
text: "red".to_string(),
};
let outcome = outcome(Some(summary), vec![("red".to_string(), vec![line])]);
let coloured = replayed_with(&outcome, None, true);
assert!(
coloured.iter().any(|l| l == "\u{1b}[31mred\u{1b}[0m"),
"{coloured:?}"
);
let plain = replayed_with(&outcome, None, false);
assert!(plain.iter().any(|l| l == "red"), "{plain:?}");
assert!(plain.iter().all(|l| !l.contains('\u{1b}')), "{plain:?}");
}
#[test]
fn an_interrupted_session_is_not_a_failure() {
assert_eq!(session_failure(Ok(WatchExit::Interrupted)), None);
}
#[test]
fn a_dead_watcher_is_reported_the_way_the_headless_session_reports_it() {
let message = session_failure(Ok(WatchExit::WatcherClosed))
.expect("a session whose watcher died must say so");
assert!(message.contains("file watcher"), "got: {message}");
}
#[tokio::test]
async fn a_session_that_panicked_is_a_failure() {
let panicked = tokio::spawn(async { panic!("the session fell over") })
.await
.expect_err("the task must have panicked");
assert!(session_failure(Err(panicked)).is_some());
}
#[test]
fn an_uninterrupted_run_reports_the_summarys_code() {
let failed = RunSummary {
failed: vec![BeamId("bad".to_string())],
..RunSummary::default()
};
let cancel = CancellationToken::new();
assert_eq!(run_exit_code(&RunSummary::default(), &cancel), 0);
assert_eq!(run_exit_code(&failed, &cancel), 1);
}
#[test]
fn an_interrupted_run_reports_130_even_though_nothing_failed() {
let cancelled = RunSummary {
cancelled: vec![BeamId("slow".to_string())],
..RunSummary::default()
};
assert_eq!(cancelled.exit_code(), 0, "precondition");
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(run_exit_code(&cancelled, &cancel), EXIT_INTERRUPTED);
}
}