diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 85d07584..37d68580 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{process::ExitStatus, time::Duration}; use bstr::BString; // Re-export ExecutionItemDisplay from vite_task_plan since it's the canonical definition @@ -44,6 +44,24 @@ pub enum CacheStatus { Hit { replayed_duration: Duration }, } +/// Convert ExitStatus to an i32 exit code. +/// On Unix, if terminated by signal, returns 128 + signal_number. +pub fn exit_status_to_code(status: &ExitStatus) -> i32 { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + status.code().unwrap_or_else(|| { + // Process was terminated by signal, use Unix convention: 128 + signal + status.signal().map(|sig| 128 + sig).unwrap_or(1) + }) + } + #[cfg(not(unix))] + { + // Windows always has an exit code + status.code().unwrap_or(1) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ExecutionId(u32); @@ -68,5 +86,5 @@ pub enum ExecutionEventKind { Start { display: Option, cache_status: CacheStatus }, Output { kind: OutputKind, content: BString }, Error { message: String }, - Finish { status: Option, cache_update_status: CacheUpdateStatus }, + Finish { status: Option, cache_update_status: CacheUpdateStatus }, } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index b62c01e4..7c9cae83 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -157,7 +157,7 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Finish { - status: Some(0), + status: None, cache_update_status: CacheUpdateStatus::NotUpdated( CacheNotUpdatedReason::CacheDisabled, ), @@ -237,7 +237,7 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Finish { - status: Some(0), + status: None, cache_update_status: CacheUpdateStatus::NotUpdated( CacheNotUpdatedReason::CacheHit, ), @@ -339,7 +339,7 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Finish { - status: result.exit_status.code(), + status: Some(result.exit_status), cache_update_status, }, }); diff --git a/crates/vite_task/src/session/reporter.rs b/crates/vite_task/src/session/reporter.rs index ef8be7d7..2d89fa3e 100644 --- a/crates/vite_task/src/session/reporter.rs +++ b/crates/vite_task/src/session/reporter.rs @@ -3,6 +3,7 @@ use std::{ collections::HashSet, io::Write, + process::ExitStatus as StdExitStatus, sync::{Arc, LazyLock}, time::Duration, }; @@ -12,7 +13,10 @@ use vite_path::AbsolutePath; use super::{ cache::{format_cache_status_inline, format_cache_status_summary}, - event::{CacheStatus, ExecutionEvent, ExecutionEventKind, ExecutionId, ExecutionItemDisplay}, + event::{ + CacheStatus, ExecutionEvent, ExecutionEventKind, ExecutionId, ExecutionItemDisplay, + exit_status_to_code, + }, }; /// Wrap of `OwoColorize` that ignores style if `NO_COLOR` is set. @@ -55,7 +59,8 @@ const CACHE_MISS_STYLE: Style = Style::new().purple(); struct ExecutionInfo { display: Option, cache_status: CacheStatus, // Non-optional, determined at Start - exit_status: Option, + /// Exit status from the process. None means no process was spawned (cache hit or in-process). + exit_status: Option, error_message: Option, } @@ -226,12 +231,11 @@ impl LabeledReporter { self.stats.failed += 1; } - fn handle_finish(&mut self, execution_id: ExecutionId, status: Option) { + fn handle_finish(&mut self, execution_id: ExecutionId, status: Option) { // Update failure statistics - if let Some(s) = status { - if s != 0 { - self.stats.failed += 1; - } + // None means success (cache hit or in-process), Some checks the actual exit status + if status.is_some_and(|s| !s.success()) { + self.stats.failed += 1; } // Update execution info exit status @@ -403,11 +407,16 @@ impl LabeledReporter { let _ = write!(self.writer, ": {}", command_display.style(COMMAND_STYLE)); // Execution result icon - match exec.exit_status { - Some(0) => { + // None means success (cache hit or in-process), Some checks actual status + match &exec.exit_status { + None => { + let _ = write!(self.writer, " {}", "✓".style(Style::new().green().bold())); + } + Some(status) if status.success() => { let _ = write!(self.writer, " {}", "✓".style(Style::new().green().bold())); } - Some(code) => { + Some(status) => { + let code = exit_status_to_code(status); let _ = write!( self.writer, " {} {}", @@ -415,9 +424,6 @@ impl LabeledReporter { format!("(exit code: {code})").style(Style::new().red()) ); } - None => { - let _ = write!(self.writer, " {}", "?".style(Style::new().bright_black())); - } } let _ = writeln!(self.writer); @@ -536,11 +542,13 @@ impl Reporter for LabeledReporter { // 1. All tasks succeed → return Ok(()) // 2. Exactly one task failed → return Err with that task's exit code // 3. More than one task failed → return Err(1) + // Note: None means success (cache hit or in-process) let failed_exit_codes: Vec = self .executions .iter() - .filter_map(|exec| exec.exit_status) - .filter(|&status| status != 0) + .filter_map(|exec| exec.exit_status.as_ref()) + .filter(|status| !status.success()) + .map(exit_status_to_code) .collect(); match failed_exit_codes.as_slice() { diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/package.json new file mode 100644 index 00000000..57783022 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/package.json @@ -0,0 +1,3 @@ +{ + "name": "signal-exit-test" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/snapshots.toml new file mode 100644 index 00000000..19f26132 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/snapshots.toml @@ -0,0 +1,9 @@ +# Tests exit code behavior for signal-terminated processes +# Unix-only: Windows doesn't have Unix signals, so exit codes differ + +[[e2e]] +name = "signal terminated task returns non-zero exit code" +platform = "unix" +steps = [ + "vite run abort # SIGABRT -> exit code 134", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/snapshots/signal terminated task returns non-zero exit code.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/snapshots/signal terminated task returns non-zero exit code.snap new file mode 100644 index 00000000..47d608f2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/snapshots/signal terminated task returns non-zero exit code.snap @@ -0,0 +1,21 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +input_file: crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit +--- +[134]> vite run abort # SIGABRT -> exit code 134 +$ node -e "process.kill(process.pid, 6)" + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses • 1 failed +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] signal-exit-test#abort: $ node -e "process.kill(process.pid, 6)" ✗ (exit code: 134) + → Cache miss: no previous cache entry found +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/vite.config.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/vite.config.json new file mode 100644 index 00000000..6407f2fb --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/signal-exit/vite.config.json @@ -0,0 +1,7 @@ +{ + "tasks": { + "abort": { + "command": "node -e \"process.kill(process.pid, 6)\"" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 8913304c..53ce4010 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -76,6 +76,9 @@ struct E2e { #[serde(default)] pub cwd: RelativePathBuf, pub steps: Vec, + /// Optional platform filter: "unix" or "windows". If set, test only runs on that platform. + #[serde(default)] + pub platform: Option, } #[derive(serde::Deserialize, Default)] @@ -165,6 +168,18 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name let mut e2e_count = 0u32; for e2e in cases_file.e2e_cases { + // Skip test if platform doesn't match + if let Some(platform) = &e2e.platform { + let should_run = match platform.as_str() { + "unix" => cfg!(unix), + "windows" => cfg!(windows), + other => panic!("Unknown platform '{}' in test '{}'", other, e2e.name), + }; + if !should_run { + continue; + } + } + let e2e_stage_path = tmpdir.join(format!("{}_e2e_stage_{}", fixture_name, e2e_count)); e2e_count += 1; assert!(copy_dir(fixture_path, &e2e_stage_path).unwrap().is_empty());