diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 07ca39c2..35b32a0c 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -7,6 +7,18 @@ use vite_task_graph::{TaskSpecifier, query::TaskQuery}; use vite_task_plan::plan_request::{CacheOverride, PlanOptions, QueryPlanRequest}; use vite_workspace::package_filter::{PackageQueryArgs, PackageQueryError}; +/// Controls how task output is displayed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum LogMode { + /// Output streams directly to the terminal as tasks produce it. + #[default] + Interleaved, + /// Each line is prefixed with `[packageName#taskName]`. + Labeled, + /// Output is buffered per task and printed as a block after each task completes. + Grouped, +} + #[derive(Debug, Clone, clap::Subcommand)] pub enum CacheSubcommand { /// Clean up all the cache @@ -35,6 +47,10 @@ pub struct RunFlags { /// Force caching off for all tasks and scripts. #[clap(long, conflicts_with = "cache")] pub no_cache: bool, + + /// How task output is displayed. + #[clap(long, default_value = "interleaved")] + pub log: LogMode, } impl RunFlags { diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 36f1fef3..8a8cad44 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -73,7 +73,7 @@ pub struct ExecutionCache { const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard(); -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[expect( clippy::large_enum_variant, reason = "FingerprintMismatch contains SpawnFingerprint which is intentionally large; boxing would add unnecessary indirection for a short-lived enum" @@ -93,7 +93,7 @@ pub enum InputChangeKind { Removed, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum FingerprintMismatch { /// Found a previous cache entry key for the same task, but the spawn fingerprint differs. /// This happens when the command itself or an env changes. diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 48efd793..a0ff4a3b 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -45,7 +45,7 @@ pub enum ExecutionError { PostRunFingerprint(#[source] anyhow::Error), } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum CacheDisabledReason { InProcessExecution, NoCacheMetadata, @@ -77,7 +77,7 @@ pub enum CacheUpdateStatus { NotUpdated(CacheNotUpdatedReason), } -#[derive(Debug)] +#[derive(Debug, Clone)] #[expect( clippy::large_enum_variant, reason = "CacheMiss variant is intentionally large and infrequently cloned" diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index bbacf80f..f520ed35 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -66,22 +66,13 @@ impl ExecutionContext<'_> { /// We compute a topological order and iterate in reverse to get execution order /// (dependencies before dependents). /// - /// `all_ancestors_single_node` tracks whether every graph in the ancestry chain - /// (from the root down to this level) contains exactly one node. The initial call - /// passes `graph.node_count() == 1`; recursive calls AND with the nested graph's - /// node count. - /// /// Fast-fail: if any task fails (non-zero exit or infrastructure error), remaining /// tasks and `&&`-chained items are skipped. Leaf-level errors are reported through /// the reporter. Cycle detection is handled at plan time. /// /// Returns `true` if all tasks succeeded, `false` if any task failed. #[tracing::instrument(level = "debug", skip_all)] - async fn execute_expanded_graph( - &mut self, - graph: &ExecutionGraph, - all_ancestors_single_node: bool, - ) -> bool { + async fn execute_expanded_graph(&mut self, graph: &ExecutionGraph) -> bool { // `compute_topological_order()` returns nodes in topological order: for every // edge A→B, A appears before B. Since our edges mean "A depends on B", // dependencies (B) appear after their dependents (A). We iterate in reverse @@ -97,23 +88,13 @@ impl ExecutionContext<'_> { for item in &task_execution.items { let failed = match &item.kind { ExecutionItemKind::Leaf(leaf_kind) => { - self.execute_leaf( - &item.execution_item_display, - leaf_kind, - all_ancestors_single_node, - ) - .boxed_local() - .await - } - ExecutionItemKind::Expanded(nested_graph) => { - !self - .execute_expanded_graph( - nested_graph, - all_ancestors_single_node && nested_graph.node_count() == 1, - ) + self.execute_leaf(&item.execution_item_display, leaf_kind) .boxed_local() .await } + ExecutionItemKind::Expanded(nested_graph) => { + !self.execute_expanded_graph(nested_graph).boxed_local().await + } }; if failed { return false; @@ -134,10 +115,8 @@ impl ExecutionContext<'_> { &mut self, display: &ExecutionItemDisplay, leaf_kind: &LeafExecutionKind, - all_ancestors_single_node: bool, ) -> bool { - let mut leaf_reporter = - self.reporter.new_leaf_execution(display, leaf_kind, all_ancestors_single_node); + let mut leaf_reporter = self.reporter.new_leaf_execution(display, leaf_kind); match leaf_kind { LeafExecutionKind::InProcess(in_process_execution) => { @@ -543,8 +522,7 @@ impl Session<'_> { // Execute the graph with fast-fail: if any task fails, remaining tasks // are skipped. Leaf-level errors are reported through the reporter. - let all_single_node = execution_graph.node_count() == 1; - execution_context.execute_expanded_graph(&execution_graph, all_single_node).await; + execution_context.execute_expanded_graph(&execution_graph).await; // Leaf-level errors and non-zero exit statuses are tracked internally // by the reporter. diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 11f687a5..1c0b58a7 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -12,7 +12,8 @@ use clap::Parser as _; use once_cell::sync::OnceCell; pub use reporter::ExitStatus; use reporter::{ - LabeledReporterBuilder, + GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder, + SummaryReporterBuilder, summary::{LastRunSummary, ReadSummaryError, format_full_summary}, }; use rustc_hash::FxHashMap; @@ -300,14 +301,33 @@ impl<'a> Session<'a> { self.plan_from_query(qpr).await? }; - let builder = LabeledReporterBuilder::new( - self.workspace_path(), + let workspace_path = self.workspace_path(); + let writer: Box = Box::new(std::io::stdout()); + + let inner: Box = + match run_command.flags.log { + crate::cli::LogMode::Interleaved => Box::new( + InterleavedReporterBuilder::new(Arc::clone(&workspace_path), writer), + ), + crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new( + Arc::clone(&workspace_path), + writer, + )), + crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new( + Arc::clone(&workspace_path), + writer, + )), + }; + + let builder = Box::new(SummaryReporterBuilder::new( + inner, + workspace_path, Box::new(std::io::stdout()), run_command.flags.verbose, Some(self.make_summary_writer()), self.program_name.clone(), - ); - self.execute_graph(graph, Box::new(builder)).await.map_err(SessionError::EarlyExit) + )); + self.execute_graph(graph, builder).await.map_err(SessionError::EarlyExit) } } } diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs new file mode 100644 index 00000000..5002a3a0 --- /dev/null +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -0,0 +1,159 @@ +//! Grouped reporter — buffers output per task, prints as a block on completion. + +use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; + +use owo_colors::Style; +use vite_path::AbsolutePath; +use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; + +use super::{ + ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + LeafExecutionReporter, StdioConfig, StdioSuggestion, format_command_with_cache_status, + format_task_label, write_leaf_trailing_output, +}; +use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; + +mod writer; + +use writer::GroupedWriter; + +pub struct GroupedReporterBuilder { + workspace_path: Arc, + writer: Box, +} + +impl GroupedReporterBuilder { + pub fn new(workspace_path: Arc, writer: Box) -> Self { + Self { workspace_path, writer } + } +} + +impl GraphExecutionReporterBuilder for GroupedReporterBuilder { + fn build(self: Box) -> Box { + Box::new(GroupedGraphReporter { + writer: Rc::new(RefCell::new(self.writer)), + workspace_path: self.workspace_path, + }) + } +} + +struct GroupedGraphReporter { + writer: Rc>>, + workspace_path: Arc, +} + +impl GraphExecutionReporter for GroupedGraphReporter { + fn new_leaf_execution( + &mut self, + display: &ExecutionItemDisplay, + _leaf_kind: &LeafExecutionKind, + ) -> Box { + let label = format_task_label(display); + Box::new(GroupedLeafReporter { + writer: Rc::clone(&self.writer), + display: display.clone(), + workspace_path: Arc::clone(&self.workspace_path), + label, + started: false, + grouped_buffer: None, + }) + } + + fn finish(self: Box) -> Result<(), ExitStatus> { + let mut writer = self.writer.borrow_mut(); + let _ = writer.flush(); + Ok(()) + } +} + +struct GroupedLeafReporter { + writer: Rc>>, + display: ExecutionItemDisplay, + workspace_path: Arc, + label: vite_str::Str, + started: bool, + grouped_buffer: Option>>>, +} + +impl LeafExecutionReporter for GroupedLeafReporter { + fn start(&mut self, cache_status: CacheStatus) -> StdioConfig { + let line = + format_command_with_cache_status(&self.display, &self.workspace_path, &cache_status); + + self.started = true; + + // Print labeled command line immediately (before output is buffered). + let labeled_line = vite_str::format!("{} {line}", self.label); + let mut writer = self.writer.borrow_mut(); + let _ = writer.write_all(labeled_line.as_bytes()); + let _ = writer.flush(); + + // Create shared buffer for both stdout and stderr. + let buffer = Rc::new(RefCell::new(Vec::new())); + self.grouped_buffer = Some(Rc::clone(&buffer)); + + StdioConfig { + suggestion: StdioSuggestion::Piped, + stdout_writer: Box::new(GroupedWriter::new(Rc::clone(&buffer))), + stderr_writer: Box::new(GroupedWriter::new(buffer)), + } + } + + fn finish( + self: Box, + _status: Option, + _cache_update_status: CacheUpdateStatus, + error: Option, + ) { + // Build grouped block: header + buffered output. + let mut extra = Vec::new(); + if let Some(ref grouped_buffer) = self.grouped_buffer { + let content = grouped_buffer.borrow(); + if !content.is_empty() { + let header = vite_str::format!( + "{} {} {}\n", + "──".style(Style::new().bright_black()), + self.label, + "──".style(Style::new().bright_black()) + ); + extra.extend_from_slice(header.as_bytes()); + extra.extend_from_slice(&content); + } + } + + write_leaf_trailing_output(&self.writer, error, self.started, &extra); + } +} + +#[cfg(test)] +mod tests { + use vite_task_plan::ExecutionItemKind; + + use super::*; + use crate::session::{ + event::CacheDisabledReason, + reporter::{ + StdioSuggestion, + test_fixtures::{spawn_task, test_path}, + }, + }; + + fn leaf_kind(item: &vite_task_plan::ExecutionItem) -> &LeafExecutionKind { + match &item.kind { + ExecutionItemKind::Leaf(kind) => kind, + ExecutionItemKind::Expanded(_) => panic!("test fixture item must be a Leaf"), + } + } + + #[test] + fn always_suggests_piped() { + let task = spawn_task("build"); + let item = &task.items[0]; + + let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink()))); + let mut reporter = builder.build(); + let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item)); + let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); + assert_eq!(stdio_config.suggestion, StdioSuggestion::Piped); + } +} diff --git a/crates/vite_task/src/session/reporter/grouped/writer.rs b/crates/vite_task/src/session/reporter/grouped/writer.rs new file mode 100644 index 00000000..6ec2acc8 --- /dev/null +++ b/crates/vite_task/src/session/reporter/grouped/writer.rs @@ -0,0 +1,60 @@ +//! A [`Write`] wrapper that buffers all output for later retrieval. + +use std::{ + cell::RefCell, + io::{self, Write}, + rc::Rc, +}; + +/// Writer that buffers all output into a shared buffer. +/// +/// Both stdout and stderr [`GroupedWriter`]s for a task share the same +/// `Rc>>` buffer, so output is naturally interleaved in +/// arrival order. The buffer is read and flushed as a block when the +/// task completes. +pub struct GroupedWriter { + buffer: Rc>>, +} + +impl GroupedWriter { + pub const fn new(buffer: Rc>>) -> Self { + Self { buffer } + } +} + +impl Write for GroupedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.borrow_mut().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + // No-op — output is flushed as a block by the reporter on task completion. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn buffers_output() { + let buffer = Rc::new(RefCell::new(Vec::new())); + let mut writer = GroupedWriter::new(Rc::clone(&buffer)); + writer.write_all(b"hello ").unwrap(); + writer.write_all(b"world").unwrap(); + assert_eq!(&*buffer.borrow(), b"hello world"); + } + + #[test] + fn shared_buffer_interleaves() { + let buffer = Rc::new(RefCell::new(Vec::new())); + let mut stdout = GroupedWriter::new(Rc::clone(&buffer)); + let mut stderr = GroupedWriter::new(Rc::clone(&buffer)); + stdout.write_all(b"out1\n").unwrap(); + stderr.write_all(b"err1\n").unwrap(); + stdout.write_all(b"out2\n").unwrap(); + assert_eq!(&*buffer.borrow(), b"out1\nerr1\nout2\n"); + } +} diff --git a/crates/vite_task/src/session/reporter/interleaved/mod.rs b/crates/vite_task/src/session/reporter/interleaved/mod.rs new file mode 100644 index 00000000..5a30ec5f --- /dev/null +++ b/crates/vite_task/src/session/reporter/interleaved/mod.rs @@ -0,0 +1,152 @@ +//! Interleaved reporter — streams output directly as tasks produce it. + +use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; + +use vite_path::AbsolutePath; +use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; + +use super::{ + ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, + StdioConfig, StdioSuggestion, format_command_with_cache_status, write_leaf_trailing_output, +}; +use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; + +pub struct InterleavedReporterBuilder { + workspace_path: Arc, + writer: Box, +} + +impl InterleavedReporterBuilder { + pub fn new(workspace_path: Arc, writer: Box) -> Self { + Self { workspace_path, writer } + } +} + +impl GraphExecutionReporterBuilder for InterleavedReporterBuilder { + fn build(self: Box) -> Box { + Box::new(InterleavedGraphReporter { + writer: Rc::new(RefCell::new(self.writer)), + workspace_path: self.workspace_path, + }) + } +} + +struct InterleavedGraphReporter { + writer: Rc>>, + workspace_path: Arc, +} + +impl GraphExecutionReporter for InterleavedGraphReporter { + fn new_leaf_execution( + &mut self, + display: &ExecutionItemDisplay, + leaf_kind: &LeafExecutionKind, + ) -> Box { + let stdio_suggestion = match leaf_kind { + LeafExecutionKind::Spawn(_) => StdioSuggestion::Inherited, + LeafExecutionKind::InProcess(_) => StdioSuggestion::Piped, + }; + + Box::new(InterleavedLeafReporter { + writer: Rc::clone(&self.writer), + display: display.clone(), + workspace_path: Arc::clone(&self.workspace_path), + stdio_suggestion, + started: false, + }) + } + + fn finish(self: Box) -> Result<(), ExitStatus> { + let mut writer = self.writer.borrow_mut(); + let _ = writer.flush(); + Ok(()) + } +} + +struct InterleavedLeafReporter { + writer: Rc>>, + display: ExecutionItemDisplay, + workspace_path: Arc, + stdio_suggestion: StdioSuggestion, + started: bool, +} + +impl LeafExecutionReporter for InterleavedLeafReporter { + fn start(&mut self, cache_status: CacheStatus) -> StdioConfig { + let line = + format_command_with_cache_status(&self.display, &self.workspace_path, &cache_status); + + self.started = true; + + let mut writer = self.writer.borrow_mut(); + let _ = writer.write_all(line.as_bytes()); + let _ = writer.flush(); + + StdioConfig { + suggestion: self.stdio_suggestion, + stdout_writer: Box::new(std::io::stdout()), + stderr_writer: Box::new(std::io::stderr()), + } + } + + fn finish( + self: Box, + _status: Option, + _cache_update_status: CacheUpdateStatus, + error: Option, + ) { + write_leaf_trailing_output(&self.writer, error, self.started, &[]); + } +} + +#[cfg(test)] +mod tests { + use vite_task_plan::ExecutionItemKind; + + use super::*; + use crate::session::{ + event::CacheDisabledReason, + reporter::{ + StdioSuggestion, + test_fixtures::{in_process_task, spawn_task, test_path}, + }, + }; + + fn leaf_kind(item: &vite_task_plan::ExecutionItem) -> &LeafExecutionKind { + match &item.kind { + ExecutionItemKind::Leaf(kind) => kind, + ExecutionItemKind::Expanded(_) => panic!("test fixture item must be a Leaf"), + } + } + + fn suggestion_for( + display: &ExecutionItemDisplay, + leaf_kind: &LeafExecutionKind, + ) -> StdioSuggestion { + let builder = + Box::new(InterleavedReporterBuilder::new(test_path(), Box::new(std::io::sink()))); + let mut reporter = builder.build(); + let mut leaf = reporter.new_leaf_execution(display, leaf_kind); + leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)).suggestion + } + + #[test] + fn spawn_suggests_inherited() { + let task = spawn_task("build"); + let item = &task.items[0]; + assert_eq!( + suggestion_for(&item.execution_item_display, leaf_kind(item)), + StdioSuggestion::Inherited + ); + } + + #[test] + fn in_process_leaf_suggests_piped() { + let task = in_process_task("echo"); + let item = &task.items[0]; + assert_eq!( + suggestion_for(&item.execution_item_display, leaf_kind(item)), + StdioSuggestion::Piped + ); + } +} diff --git a/crates/vite_task/src/session/reporter/labeled.rs b/crates/vite_task/src/session/reporter/labeled.rs deleted file mode 100644 index 2b51bd1a..00000000 --- a/crates/vite_task/src/session/reporter/labeled.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! Labeled reporter family — graph-aware reporter with aggregation and summary. -//! -//! Provides the full reporter lifecycle: -//! - [`LabeledReporterBuilder`] → [`LabeledGraphReporter`] → [`LabeledLeafReporter`] -//! -//! Tracks statistics across multiple leaf executions, prints command lines with cache -//! status indicators, and renders a summary with per-task details at the end. - -use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; - -use vite_path::AbsolutePath; -use vite_str::Str; -use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; - -use super::{ - ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, - StdioConfig, StdioSuggestion, format_command_with_cache_status, format_error_message, -}; -use crate::session::{ - event::{CacheStatus, CacheUpdateStatus, ExecutionError}, - reporter::summary::{ - LastRunSummary, SavedExecutionError, SpawnOutcome, TaskResult, TaskSummary, - format_compact_summary, format_full_summary, - }, -}; - -/// Callback type for persisting the summary (e.g., writing `last-summary.json`). -type WriteSummaryFn = Box; - -/// Mutable state shared between [`LabeledGraphReporter`] and its [`LabeledLeafReporter`] instances -/// via `Rc>`. -/// -/// This is safe because execution is single-threaded and sequential — only one leaf -/// reporter is active at a time. -struct SharedReporterState { - tasks: Vec, -} - -/// Builder for the labeled graph reporter. -/// -/// Created by the caller before execution, then transitioned to [`LabeledGraphReporter`] -/// by calling `build()` with the execution graph. -/// -/// # Output Modes -/// -/// ## Compact Summary (default) -/// - Single task + not cache hit → no summary at all -/// - Single task + cache hit → thin line + "vp run: cache hit, {duration} saved." -/// - Multi-task → thin line + one-liner with stats -/// -/// ## Full Summary (`--verbose`) -/// - Shows full Statistics, Performance, and Task Details sections -pub struct LabeledReporterBuilder { - workspace_path: Arc, - writer: Box, - /// Whether to render the full detailed summary (`--verbose` flag). - show_details: bool, - /// Callback to persist the summary (e.g., write `last-summary.json`). - /// `None` when persistence is not needed (e.g., nested script execution, tests). - write_summary: Option, - program_name: Str, -} - -impl LabeledReporterBuilder { - /// Create a new labeled reporter builder. - /// - /// - `workspace_path`: The workspace root, used to compute relative cwds in display. - /// - `writer`: Writer for reporter display output. - /// - `show_details`: Whether to render the full detailed summary. - /// - `write_summary`: Callback to persist the summary, or `None` to skip. - /// - `program_name`: The CLI binary name (e.g. `"vt"`) used in summary output. - pub fn new( - workspace_path: Arc, - writer: Box, - show_details: bool, - write_summary: Option, - program_name: Str, - ) -> Self { - Self { workspace_path, writer, show_details, write_summary, program_name } - } -} - -impl GraphExecutionReporterBuilder for LabeledReporterBuilder { - fn build(self: Box) -> Box { - let writer = Rc::new(RefCell::new(self.writer)); - Box::new(LabeledGraphReporter { - shared: Rc::new(RefCell::new(SharedReporterState { tasks: Vec::new() })), - writer, - workspace_path: self.workspace_path, - show_details: self.show_details, - write_summary: self.write_summary, - program_name: self.program_name, - }) - } -} - -/// Graph-level reporter that tracks multiple leaf executions and prints a summary. -/// -/// Creates [`LabeledLeafReporter`] instances for each leaf execution. The leaf reporters -/// share mutable state with this reporter via `Rc>`. -pub struct LabeledGraphReporter { - shared: Rc>, - writer: Rc>>, - workspace_path: Arc, - show_details: bool, - write_summary: Option, - program_name: Str, -} - -impl GraphExecutionReporter for LabeledGraphReporter { - fn new_leaf_execution( - &mut self, - display: &ExecutionItemDisplay, - leaf_kind: &LeafExecutionKind, - all_ancestors_single_node: bool, - ) -> Box { - let display = display.clone(); - let stdio_suggestion = match leaf_kind { - LeafExecutionKind::Spawn(_) if all_ancestors_single_node => StdioSuggestion::Inherited, - _ => StdioSuggestion::Piped, - }; - - Box::new(LabeledLeafReporter { - shared: Rc::clone(&self.shared), - writer: Rc::clone(&self.writer), - display, - workspace_path: Arc::clone(&self.workspace_path), - stdio_suggestion, - cache_status: None, - }) - } - - fn finish(self: Box) -> Result<(), ExitStatus> { - // Take tasks from shared state — all leaf reporters have been dropped by now. - let tasks = { - let mut shared = self.shared.borrow_mut(); - std::mem::take(&mut shared.tasks) - }; - - // Compute exit status from the collected task results. - let has_infra_errors = tasks.iter().any(|t| t.result.error().is_some()); - - let failed_exit_codes: Vec = tasks - .iter() - .filter_map(|t| match &t.result { - TaskResult::Spawned { outcome: SpawnOutcome::Failed { exit_code }, .. } => { - Some(exit_code.get()) - } - _ => None, - }) - .collect(); - - let result = match (has_infra_errors, failed_exit_codes.as_slice()) { - (false, []) => Ok(()), - (false, [code]) => - { - #[expect( - clippy::cast_sign_loss, - reason = "value is clamped to 1..=255, always positive" - )] - Err(ExitStatus((*code).clamp(1, 255) as u8)) - } - _ => Err(ExitStatus::FAILURE), - }; - - let exit_code = match &result { - Ok(()) => 0u8, - Err(status) => status.0, - }; - - // Build summary from collected tasks. - let summary = LastRunSummary { tasks, exit_code }; - - // Render summary based on mode. - let summary_buf = if self.show_details { - format_full_summary(&summary) - } else { - format_compact_summary(&summary, &self.program_name) - }; - - // Persist summary via callback (best-effort, callback handles errors). - if let Some(write_summary) = self.write_summary { - write_summary(&summary); - } - - // Write the summary buffer. - // Always flush the writer — even when the summary is empty, a preceding - // spawned process may have written to the same fd via Stdio::inherit() - // and the data must be flushed before the caller reads the output. - { - let mut writer = self.writer.borrow_mut(); - if !summary_buf.is_empty() { - let _ = writer.write_all(&summary_buf); - } - let _ = writer.flush(); - } - - result - } -} - -/// Leaf-level reporter created by [`LabeledGraphReporter::new_leaf_execution`]. -/// -/// Writes display output in real-time to the shared writer and builds -/// [`TaskSummary`] entries that are pushed to [`SharedReporterState`] on completion. -struct LabeledLeafReporter { - shared: Rc>, - writer: Rc>>, - /// Display info for this execution, looked up from the graph via the path. - display: ExecutionItemDisplay, - workspace_path: Arc, - /// Stdio suggestion precomputed from this leaf's graph path. - stdio_suggestion: StdioSuggestion, - /// Cache status, set at `start()` time. `None` means `start()` was never called - /// (e.g., cache lookup failure). Consumed in `finish()` to build [`TaskSummary`]. - cache_status: Option, -} - -impl LeafExecutionReporter for LabeledLeafReporter { - fn start(&mut self, cache_status: CacheStatus) -> StdioConfig { - // Format command line with cache status before storing it. - let line = - format_command_with_cache_status(&self.display, &self.workspace_path, &cache_status); - - self.cache_status = Some(cache_status); - - let mut writer = self.writer.borrow_mut(); - let _ = writer.write_all(line.as_bytes()); - let _ = writer.flush(); - - StdioConfig { - suggestion: self.stdio_suggestion, - stdout_writer: Box::new(std::io::stdout()), - stderr_writer: Box::new(std::io::stderr()), - } - } - - fn finish( - self: Box, - status: Option, - cache_update_status: CacheUpdateStatus, - error: Option, - ) { - // Convert error before consuming it (need the original for display formatting). - let saved_error = error.as_ref().map(SavedExecutionError::from_execution_error); - let error_display: Option = - error.map(|e| vite_str::format!("{:#}", anyhow::Error::from(e))); - - // Destructure self to avoid partial-move issues with Box. - let Self { shared, writer, display, workspace_path, cache_status, .. } = *self; - let started = cache_status.is_some(); - - // Build TaskSummary and push to shared state if start() was called. - if let Some(cache_status) = cache_status { - let cwd_relative = if let Ok(Some(rel)) = display.cwd.strip_prefix(&workspace_path) { - Str::from(rel.as_str()) - } else { - Str::default() - }; - - let task_summary = TaskSummary { - package_name: display.task_display.package_name.clone(), - task_name: display.task_display.task_name.clone(), - command: display.command, - cwd: cwd_relative, - result: TaskResult::from_execution( - &cache_status, - status, - saved_error.as_ref(), - &cache_update_status, - ), - }; - - shared.borrow_mut().tasks.push(task_summary); - } - - // Build all display output into a buffer, then write once. - let mut buf = Vec::new(); - - if let Some(ref message) = error_display { - buf.extend_from_slice(format_error_message(message).as_bytes()); - } - - // Add a trailing newline after each task's output for readability. - // Skip if start() was never called (e.g. cache lookup failure) — there's - // no task output to separate. - if started { - buf.push(b'\n'); - } - - if !buf.is_empty() { - let mut writer = writer.borrow_mut(); - let _ = writer.write_all(&buf); - let _ = writer.flush(); - } - } -} - -#[cfg(test)] -mod tests { - use vite_task_plan::ExecutionItemKind; - - use super::*; - use crate::session::{ - event::CacheDisabledReason, - reporter::{ - LeafExecutionReporter, StdioSuggestion, - test_fixtures::{in_process_task, spawn_task, test_path}, - }, - }; - - /// Extract the `LeafExecutionKind` from a test fixture item. - /// Panics if the item is not a leaf (test fixtures always produce leaves). - fn leaf_kind(item: &vite_task_plan::ExecutionItem) -> &LeafExecutionKind { - match &item.kind { - ExecutionItemKind::Leaf(kind) => kind, - ExecutionItemKind::Expanded(_) => panic!("test fixture item must be a Leaf"), - } - } - - fn build_labeled_leaf( - display: &ExecutionItemDisplay, - leaf_kind: &LeafExecutionKind, - all_ancestors_single_node: bool, - ) -> Box { - let builder = Box::new(LabeledReporterBuilder::new( - test_path(), - Box::new(std::io::sink()), - false, - None, - Str::from("vt"), - )); - let mut reporter = builder.build(); - reporter.new_leaf_execution(display, leaf_kind, all_ancestors_single_node) - } - - fn suggestion_for( - display: &ExecutionItemDisplay, - leaf_kind: &LeafExecutionKind, - all_ancestors_single_node: bool, - ) -> StdioSuggestion { - let mut leaf = build_labeled_leaf(display, leaf_kind, all_ancestors_single_node); - let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); - stdio_config.suggestion - } - - #[test] - fn spawn_with_all_single_node_ancestors_suggests_inherited() { - let task = spawn_task("build"); - let item = &task.items[0]; - assert_eq!( - suggestion_for(&item.execution_item_display, leaf_kind(item), true), - StdioSuggestion::Inherited - ); - } - - #[test] - fn spawn_without_all_single_node_ancestors_suggests_piped() { - let task = spawn_task("build"); - let item = &task.items[0]; - assert_eq!( - suggestion_for(&item.execution_item_display, leaf_kind(item), false), - StdioSuggestion::Piped - ); - } - - #[test] - fn in_process_leaf_suggests_piped_even_with_single_node_ancestors() { - let task = in_process_task("echo"); - let item = &task.items[0]; - assert_eq!( - suggestion_for(&item.execution_item_display, leaf_kind(item), true), - StdioSuggestion::Piped - ); - } - - #[test] - fn in_process_leaf_suggests_piped_without_single_node_ancestors() { - let task = in_process_task("echo"); - let item = &task.items[0]; - assert_eq!( - suggestion_for(&item.execution_item_display, leaf_kind(item), false), - StdioSuggestion::Piped - ); - } -} diff --git a/crates/vite_task/src/session/reporter/labeled/mod.rs b/crates/vite_task/src/session/reporter/labeled/mod.rs new file mode 100644 index 00000000..01c63435 --- /dev/null +++ b/crates/vite_task/src/session/reporter/labeled/mod.rs @@ -0,0 +1,141 @@ +//! Labeled reporter — prefixes each output line with `[pkg#task]`. + +use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; + +use vite_path::AbsolutePath; +use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; + +use super::{ + ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, + StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label, + write_leaf_trailing_output, +}; +use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; + +mod writer; + +use writer::LabeledWriter; + +pub struct LabeledReporterBuilder { + workspace_path: Arc, + writer: Box, +} + +impl LabeledReporterBuilder { + pub fn new(workspace_path: Arc, writer: Box) -> Self { + Self { workspace_path, writer } + } +} + +impl GraphExecutionReporterBuilder for LabeledReporterBuilder { + fn build(self: Box) -> Box { + Box::new(LabeledGraphReporter { + writer: Rc::new(RefCell::new(self.writer)), + workspace_path: self.workspace_path, + }) + } +} + +struct LabeledGraphReporter { + writer: Rc>>, + workspace_path: Arc, +} + +impl GraphExecutionReporter for LabeledGraphReporter { + fn new_leaf_execution( + &mut self, + display: &ExecutionItemDisplay, + _leaf_kind: &LeafExecutionKind, + ) -> Box { + Box::new(LabeledLeafReporter { + writer: Rc::clone(&self.writer), + display: display.clone(), + workspace_path: Arc::clone(&self.workspace_path), + started: false, + }) + } + + fn finish(self: Box) -> Result<(), ExitStatus> { + let mut writer = self.writer.borrow_mut(); + let _ = writer.flush(); + Ok(()) + } +} + +struct LabeledLeafReporter { + writer: Rc>>, + display: ExecutionItemDisplay, + workspace_path: Arc, + started: bool, +} + +impl LeafExecutionReporter for LabeledLeafReporter { + fn start(&mut self, cache_status: CacheStatus) -> StdioConfig { + let label = format_task_label(&self.display); + let line = + format_command_with_cache_status(&self.display, &self.workspace_path, &cache_status); + + self.started = true; + + let labeled_line = vite_str::format!("{label} {line}"); + let mut writer = self.writer.borrow_mut(); + let _ = writer.write_all(labeled_line.as_bytes()); + let _ = writer.flush(); + + let prefix = vite_str::format!("{label} "); + + StdioConfig { + suggestion: StdioSuggestion::Piped, + stdout_writer: Box::new(LabeledWriter::new( + Box::new(std::io::stdout()), + prefix.as_bytes().to_vec(), + )), + stderr_writer: Box::new(LabeledWriter::new( + Box::new(std::io::stderr()), + prefix.as_bytes().to_vec(), + )), + } + } + + fn finish( + self: Box, + _status: Option, + _cache_update_status: CacheUpdateStatus, + error: Option, + ) { + write_leaf_trailing_output(&self.writer, error, self.started, &[]); + } +} + +#[cfg(test)] +mod tests { + use vite_task_plan::ExecutionItemKind; + + use super::*; + use crate::session::{ + event::CacheDisabledReason, + reporter::{ + StdioSuggestion, + test_fixtures::{spawn_task, test_path}, + }, + }; + + fn leaf_kind(item: &vite_task_plan::ExecutionItem) -> &LeafExecutionKind { + match &item.kind { + ExecutionItemKind::Leaf(kind) => kind, + ExecutionItemKind::Expanded(_) => panic!("test fixture item must be a Leaf"), + } + } + + #[test] + fn always_suggests_piped() { + let task = spawn_task("build"); + let item = &task.items[0]; + + let builder = Box::new(LabeledReporterBuilder::new(test_path(), Box::new(std::io::sink()))); + let mut reporter = builder.build(); + let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item)); + let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); + assert_eq!(stdio_config.suggestion, StdioSuggestion::Piped); + } +} diff --git a/crates/vite_task/src/session/reporter/labeled/writer.rs b/crates/vite_task/src/session/reporter/labeled/writer.rs new file mode 100644 index 00000000..65440fd9 --- /dev/null +++ b/crates/vite_task/src/session/reporter/labeled/writer.rs @@ -0,0 +1,141 @@ +//! A [`Write`] wrapper that prefixes each line with a label (e.g., `[pkg#task] `). + +use std::io::{self, Write}; + +/// Writer that prefixes each complete line with a label. +/// +/// Data is buffered internally. On [`flush`](Write::flush), complete lines +/// (terminated by `\n`) are written to the inner writer with the prefix +/// prepended. Any trailing partial line is kept in the buffer until the +/// next flush. +pub struct LabeledWriter { + inner: Box, + prefix: Vec, + buffer: Vec, +} + +impl LabeledWriter { + pub fn new(inner: Box, prefix: Vec) -> Self { + Self { inner, prefix, buffer: Vec::new() } + } +} + +impl Write for LabeledWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + // Find the last newline — everything up to (and including) it can be + // split into complete lines and written with prefixes. + let last_nl = self.buffer.iter().rposition(|&b| b == b'\n'); + let Some(last_nl) = last_nl else { + // No complete lines yet — keep buffering. + return Ok(()); + }; + + // Split off the complete portion (0..=last_nl) from any trailing partial line. + let remaining = self.buffer.split_off(last_nl + 1); + let complete = std::mem::replace(&mut self.buffer, remaining); + + // Batch prefix + line into a single write to reduce syscall overhead. + let mut prefixed = Vec::new(); + for line in complete.split_inclusive(|&b| b == b'\n') { + prefixed.extend_from_slice(&self.prefix); + prefixed.extend_from_slice(line); + } + self.inner.write_all(&prefixed)?; + + self.inner.flush() + } +} + +impl Drop for LabeledWriter { + fn drop(&mut self) { + // Flush any remaining partial line on drop. + if !self.buffer.is_empty() { + let buf = std::mem::take(&mut self.buffer); + let _ = self.inner.write_all(&self.prefix); + let _ = self.inner.write_all(&buf); + let _ = self.inner.flush(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn labeled_output(prefix: &str, chunks: &[&[u8]]) -> Vec { + let output = Vec::new(); + let mut writer = LabeledWriter::new(Box::new(output), prefix.as_bytes().to_vec()); + for chunk in chunks { + writer.write_all(chunk).unwrap(); + writer.flush().unwrap(); + } + drop(writer); + // We need to get the inner writer back — reconstruct by running again + // and capturing output via a shared buffer. + let output = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let output_clone = std::rc::Rc::clone(&output); + { + let mut writer = + LabeledWriter::new(Box::new(RcWriter(output_clone)), prefix.as_bytes().to_vec()); + for chunk in chunks { + writer.write_all(chunk).unwrap(); + writer.flush().unwrap(); + } + } + std::rc::Rc::try_unwrap(output).unwrap().into_inner() + } + + struct RcWriter(std::rc::Rc>>); + + impl Write for RcWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.borrow_mut().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn single_complete_line() { + let result = labeled_output("[app] ", &[b"hello\n"]); + assert_eq!(result, b"[app] hello\n"); + } + + #[test] + fn multiple_lines_in_one_chunk() { + let result = labeled_output("[a] ", &[b"line1\nline2\n"]); + assert_eq!(result, b"[a] line1\n[a] line2\n"); + } + + #[test] + fn partial_line_flushed_on_drop() { + let result = labeled_output("[x] ", &[b"no newline"]); + assert_eq!(result, b"[x] no newline"); + } + + #[test] + fn split_across_chunks() { + let result = labeled_output("[p] ", &[b"split ", b"line\n"]); + assert_eq!(result, b"[p] split line\n"); + } + + #[test] + fn empty_line() { + let result = labeled_output("[e] ", &[b"\n"]); + assert_eq!(result, b"[e] \n"); + } + + #[test] + fn multiple_flushes_with_partial() { + let result = labeled_output("[m] ", &[b"a\nb", b"c\n"]); + assert_eq!(result, b"[m] a\n[m] bc\n"); + } +} diff --git a/crates/vite_task/src/session/reporter/mod.rs b/crates/vite_task/src/session/reporter/mod.rs index b61c2cb4..64f5f441 100644 --- a/crates/vite_task/src/session/reporter/mod.rs +++ b/crates/vite_task/src/session/reporter/mod.rs @@ -11,26 +11,33 @@ //! 3. [`LeafExecutionReporter`] — handles events for a single leaf execution (output streaming, //! cache status, errors). Finalized with `finish()`. //! -//! Two concrete implementations are provided in child modules: +//! Three output mode reporters are provided: //! -//! - [`plain::PlainReporter`] — a standalone [`LeafExecutionReporter`] for single-leaf execution -//! (e.g., `execute_synthetic`). Self-contained, no shared state, no summary. +//! - [`interleaved::InterleavedReporterBuilder`] — streams output directly as tasks produce it. +//! - [`labeled::LabeledReporterBuilder`] — prefixes each output line with `[pkg#task]`. +//! - [`grouped::GroupedReporterBuilder`] — buffers output per task and prints as a block. //! -//! - [`labeled::LabeledReporterBuilder`] / [`labeled::LabeledGraphReporter`] / -//! `LabeledLeafReporter` — a full graph-aware reporter family. Tracks stats across multiple -//! leaf executions, prints command lines with cache status, and renders a summary at the end. +//! [`summary_reporter::SummaryReporterBuilder`] wraps any mode reporter to add summary +//! tracking (task results, exit codes, cache stats) and renders the summary at the end. +//! +//! Additionally, [`plain::PlainReporter`] is a standalone [`LeafExecutionReporter`] for +//! single-leaf synthetic executions (e.g., `execute_synthetic`). +mod grouped; +mod interleaved; mod labeled; mod plain; pub mod summary; +mod summary_reporter; -// Re-export the concrete implementations so callers can use `reporter::PlainReporter` -// and `reporter::LabeledReporterBuilder` without navigating into child modules. use std::{io::Write, process::ExitStatus as StdExitStatus, sync::LazyLock}; +pub use grouped::GroupedReporterBuilder; +pub use interleaved::InterleavedReporterBuilder; pub use labeled::LabeledReporterBuilder; use owo_colors::{Style, Styled}; pub use plain::PlainReporter; +pub use summary_reporter::SummaryReporterBuilder; use vite_path::AbsolutePath; use vite_str::Str; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; @@ -114,16 +121,10 @@ pub trait GraphExecutionReporterBuilder { /// and finalizes the session with `finish()`. pub trait GraphExecutionReporter { /// Create a new leaf execution reporter for the given leaf. - /// - /// `all_ancestors_single_node` is `true` when every execution graph in - /// the ancestry chain (root + all nested `Expanded` parents) contains - /// exactly one node. The reporter may use this to decide stdio mode - /// (e.g. suggesting inherited stdio for a single spawned process). fn new_leaf_execution( &mut self, display: &ExecutionItemDisplay, leaf_kind: &LeafExecutionKind, - all_ancestors_single_node: bool, ) -> Box; /// Finalize the graph execution session. @@ -205,6 +206,14 @@ fn format_cwd_relative(display: &ExecutionItemDisplay, workspace_path: &Absolute if cwd_relative.is_empty() { Str::default() } else { vite_str::format!("~/{cwd_relative}") } } +/// Format the task label for labeled/grouped modes (e.g., `[pkg#task]`). +fn format_task_label(display: &ExecutionItemDisplay) -> Str { + vite_str::format!( + "{}", + vite_str::format!("[{}]", display.task_display).style(Style::new().bright_black()) + ) +} + /// Format the command string with cwd prefix for display (e.g., `~/packages/lib$ vitest run`). fn format_command_display(display: &ExecutionItemDisplay, workspace_path: &AbsolutePath) -> Str { let cwd_str = format_cwd_relative(display, workspace_path); @@ -256,6 +265,34 @@ fn format_error_message(message: &str) -> Str { ) } +/// Write the trailing output for a leaf execution: optional extra content (e.g., grouped +/// output block), error message, and a separating newline. +fn write_leaf_trailing_output( + writer: &std::cell::RefCell>, + error: Option, + started: bool, + extra: &[u8], +) { + let mut buf = Vec::new(); + + buf.extend_from_slice(extra); + + if let Some(error) = error { + let message = vite_str::format!("{:#}", anyhow::Error::from(error)); + buf.extend_from_slice(format_error_message(&message).as_bytes()); + } + + if started { + buf.push(b'\n'); + } + + if !buf.is_empty() { + let mut writer = writer.borrow_mut(); + let _ = writer.write_all(&buf); + let _ = writer.flush(); + } +} + /// Format the "cache hit, logs replayed" message for synthetic executions without display info. fn format_cache_hit_message() -> Str { vite_str::format!("{}\n", "◉ cache hit, logs replayed".style(Style::new().green().dimmed())) diff --git a/crates/vite_task/src/session/reporter/summary_reporter.rs b/crates/vite_task/src/session/reporter/summary_reporter.rs new file mode 100644 index 00000000..06d9f975 --- /dev/null +++ b/crates/vite_task/src/session/reporter/summary_reporter.rs @@ -0,0 +1,204 @@ +//! Summary reporter — wraps an inner reporter and adds summary tracking. +//! +//! This is a decorator that intercepts leaf `start()`/`finish()` to track task +//! results, then renders a summary when the graph execution completes. The inner +//! reporter handles all output formatting (interleaved, labeled, grouped). + +use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; + +use vite_path::AbsolutePath; +use vite_str::Str; +use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; + +use super::{ + ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, + StdioConfig, +}; +use crate::session::{ + event::{CacheStatus, CacheUpdateStatus, ExecutionError}, + reporter::summary::{ + LastRunSummary, SavedExecutionError, SpawnOutcome, TaskResult, TaskSummary, + format_compact_summary, format_full_summary, + }, +}; + +/// Callback type for persisting the summary (e.g., writing `last-summary.json`). +pub type WriteSummaryFn = Box; + +/// Builder that wraps an inner builder and adds summary tracking. +pub struct SummaryReporterBuilder { + inner: Box, + workspace_path: Arc, + writer: Box, + show_details: bool, + write_summary: Option, + program_name: Str, +} + +impl SummaryReporterBuilder { + pub fn new( + inner: Box, + workspace_path: Arc, + writer: Box, + show_details: bool, + write_summary: Option, + program_name: Str, + ) -> Self { + Self { inner, workspace_path, writer, show_details, write_summary, program_name } + } +} + +impl GraphExecutionReporterBuilder for SummaryReporterBuilder { + fn build(self: Box) -> Box { + Box::new(SummaryGraphReporter { + inner: self.inner.build(), + tasks: Rc::new(RefCell::new(Vec::new())), + workspace_path: self.workspace_path, + writer: self.writer, + show_details: self.show_details, + write_summary: self.write_summary, + program_name: self.program_name, + }) + } +} + +struct SummaryGraphReporter { + inner: Box, + tasks: Rc>>, + workspace_path: Arc, + writer: Box, + show_details: bool, + write_summary: Option, + program_name: Str, +} + +impl GraphExecutionReporter for SummaryGraphReporter { + fn new_leaf_execution( + &mut self, + display: &ExecutionItemDisplay, + leaf_kind: &LeafExecutionKind, + ) -> Box { + let inner = self.inner.new_leaf_execution(display, leaf_kind); + Box::new(SummaryLeafReporter { + inner, + tasks: Rc::clone(&self.tasks), + display: display.clone(), + workspace_path: Arc::clone(&self.workspace_path), + cache_status: None, + }) + } + + fn finish(self: Box) -> Result<(), ExitStatus> { + // Let inner reporter finish first (flushes any pending output). + let inner_result = self.inner.finish(); + + let tasks = self.tasks.take(); + + let has_infra_errors = tasks.iter().any(|t| t.result.error().is_some()); + + let failed_exit_codes: Vec = tasks + .iter() + .filter_map(|t| match &t.result { + TaskResult::Spawned { outcome: SpawnOutcome::Failed { exit_code }, .. } => { + Some(exit_code.get()) + } + _ => None, + }) + .collect(); + + let result = match (has_infra_errors, failed_exit_codes.as_slice()) { + (false, []) => Ok(()), + (false, [code]) => + { + #[expect( + clippy::cast_sign_loss, + reason = "value is clamped to 1..=255, always positive" + )] + Err(ExitStatus((*code).clamp(1, 255) as u8)) + } + _ => Err(ExitStatus::FAILURE), + }; + + let exit_code = match &result { + Ok(()) => 0u8, + Err(status) => status.0, + }; + + let summary = LastRunSummary { tasks, exit_code }; + + let summary_buf = if self.show_details { + format_full_summary(&summary) + } else { + format_compact_summary(&summary, &self.program_name) + }; + + if let Some(write_summary) = self.write_summary { + write_summary(&summary); + } + + // Always flush — even when summary is empty, a preceding spawned process + // may have written to the same fd via Stdio::inherit(). + { + let mut writer = self.writer; + if !summary_buf.is_empty() { + let _ = writer.write_all(&summary_buf); + } + let _ = writer.flush(); + } + + // Use inner result if it failed, otherwise use our computed result. + inner_result.and(result) + } +} + +/// Leaf reporter wrapper that records task results for the summary. +struct SummaryLeafReporter { + inner: Box, + tasks: Rc>>, + display: ExecutionItemDisplay, + workspace_path: Arc, + cache_status: Option, +} + +impl LeafExecutionReporter for SummaryLeafReporter { + fn start(&mut self, cache_status: CacheStatus) -> StdioConfig { + self.cache_status = Some(cache_status.clone()); + self.inner.start(cache_status) + } + + fn finish( + self: Box, + status: Option, + cache_update_status: CacheUpdateStatus, + error: Option, + ) { + // Record task summary before forwarding to inner. + let saved_error = error.as_ref().map(SavedExecutionError::from_execution_error); + + if let Some(ref cache_status) = self.cache_status { + let cwd_relative = + if let Ok(Some(rel)) = self.display.cwd.strip_prefix(&self.workspace_path) { + Str::from(rel.as_str()) + } else { + Str::default() + }; + + let task_summary = TaskSummary { + package_name: self.display.task_display.package_name.clone(), + task_name: self.display.task_display.task_name.clone(), + command: self.display.command.clone(), + cwd: cwd_relative, + result: TaskResult::from_execution( + cache_status, + status, + saved_error.as_ref(), + &cache_update_status, + ), + }; + + self.tasks.borrow_mut().push(task_summary); + } + + self.inner.finish(status, cache_update_status, error); + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/package.json new file mode 100644 index 00000000..3850a3ac --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/package.json @@ -0,0 +1,4 @@ +{ + "name": "grouped-stdio-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/packages/other/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/packages/other/package.json similarity index 52% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/packages/other/package.json rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/packages/other/package.json index 68b67a9e..2821c718 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/packages/other/package.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/packages/other/package.json @@ -1,6 +1,8 @@ { "name": "other", "scripts": { + "check-tty": "check-tty", + "check-tty-cached": "check-tty", "read-stdin": "read-stdin" } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots.toml new file mode 100644 index 00000000..066c3d3f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots.toml @@ -0,0 +1,42 @@ +# Tests stdio behavior in grouped mode (--log=grouped). +# +# In grouped mode, stdio is always piped regardless of cache state: +# - stdin is /dev/null +# - stdout/stderr are buffered per task and printed as a block on completion +# +# `check-tty` prints whether each stdio fd is a TTY. +# `read-stdin` reads one line from stdin and prints it. + +# ─── stdout/stderr: always piped (not-tty), output grouped ─── + +[[e2e]] +name = "single task, cache off, grouped output" +steps = ["vt run --log=grouped check-tty"] + +[[e2e]] +name = "multiple tasks, cache off, grouped output" +steps = ["vt run --log=grouped -r check-tty"] + +[[e2e]] +name = "single task, cache miss, grouped output" +steps = ["vt run --log=grouped check-tty-cached"] + +[[e2e]] +name = "multiple tasks, cache miss, grouped output" +steps = ["vt run --log=grouped -r check-tty-cached"] + +# ─── cache hit → replayed in grouped blocks ─────────────────── + +[[e2e]] +name = "single task, cache hit, replayed" +steps = ["vt run --log=grouped check-tty-cached", "vt run --log=grouped check-tty-cached"] + +[[e2e]] +name = "multiple tasks, cache hit, replayed" +steps = ["vt run --log=grouped -r check-tty-cached", "vt run --log=grouped -r check-tty-cached"] + +# ─── stdin: always null ─────────────────────────────────────── + +[[e2e]] +name = "stdin is always null" +steps = ["echo from-stdin | vt run --log=grouped read-stdin"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache hit, replayed.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache hit, replayed.snap new file mode 100644 index 00000000..128beaae --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache hit, replayed.snap @@ -0,0 +1,34 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=grouped -r check-tty-cached +[other#check-tty-cached] ~/packages/other$ check-tty +── [other#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +[grouped-stdio-test#check-tty-cached] $ check-tty +── [grouped-stdio-test#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) +> vt run --log=grouped -r check-tty-cached +[other#check-tty-cached] ~/packages/other$ check-tty ◉ cache hit, replaying +── [other#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +[grouped-stdio-test#check-tty-cached] $ check-tty ◉ cache hit, replaying +── [grouped-stdio-test#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +--- +vt run: 2/2 cache hit (100%), saved. (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache miss, grouped output.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache miss, grouped output.snap new file mode 100644 index 00000000..39d55242 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache miss, grouped output.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=grouped -r check-tty-cached +[other#check-tty-cached] ~/packages/other$ check-tty +── [other#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +[grouped-stdio-test#check-tty-cached] $ check-tty +── [grouped-stdio-test#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots/multi-node ancestor forces piped for nested single-node graph.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache off, grouped output.snap similarity index 53% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots/multi-node ancestor forces piped for nested single-node graph.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache off, grouped output.snap index 266dc666..d1093984 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots/multi-node ancestor forces piped for nested single-node graph.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/multiple tasks, cache off, grouped output.snap @@ -2,13 +2,15 @@ source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs --- -> vt run -r foo-nested -~/packages/other$ check-tty ⊘ cache disabled +> vt run --log=grouped -r check-tty +[other#check-tty] ~/packages/other$ check-tty +── [other#check-tty] ── stdin:not-tty stdout:not-tty stderr:not-tty -$ check-tty ⊘ cache disabled +[grouped-stdio-test#check-tty] $ check-tty ⊘ cache disabled +── [grouped-stdio-test#check-tty] ── stdin:not-tty stdout:not-tty stderr:not-tty diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache hit, replayed.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache hit, replayed.snap new file mode 100644 index 00000000..75b09a8e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache hit, replayed.snap @@ -0,0 +1,19 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=grouped check-tty-cached +[grouped-stdio-test#check-tty-cached] $ check-tty +── [grouped-stdio-test#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty +> vt run --log=grouped check-tty-cached +[grouped-stdio-test#check-tty-cached] $ check-tty ◉ cache hit, replaying +── [grouped-stdio-test#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty + +--- +vt run: cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache miss, grouped output.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache miss, grouped output.snap new file mode 100644 index 00000000..eb058679 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache miss, grouped output.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=grouped check-tty-cached +[grouped-stdio-test#check-tty-cached] $ check-tty +── [grouped-stdio-test#check-tty-cached] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache off, grouped output.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache off, grouped output.snap new file mode 100644 index 00000000..e2078511 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/single task, cache off, grouped output.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=grouped check-tty +[grouped-stdio-test#check-tty] $ check-tty ⊘ cache disabled +── [grouped-stdio-test#check-tty] ── +stdin:not-tty +stdout:not-tty +stderr:not-tty diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/stdin is always null.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/stdin is always null.snap new file mode 100644 index 00000000..37320def --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/snapshots/stdin is always null.snap @@ -0,0 +1,6 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo from-stdin | vt run --log=grouped read-stdin +[grouped-stdio-test#read-stdin] $ read-stdin ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/vite-task.json similarity index 55% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/vite-task.json rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/vite-task.json index 3d530052..938ac52e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/grouped-stdio/vite-task.json @@ -1,6 +1,14 @@ { "cache": true, "tasks": { + "check-tty": { + "command": "check-tty", + "cache": false + }, + "check-tty-cached": { + "command": "check-tty", + "cache": true + }, "read-stdin": { "command": "read-stdin", "cache": false diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/package.json new file mode 100644 index 00000000..59356dd6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/package.json @@ -0,0 +1,4 @@ +{ + "name": "interleaved-stdio-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/packages/other/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/packages/other/package.json similarity index 51% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/packages/other/package.json rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/packages/other/package.json index 48d6c781..2821c718 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/packages/other/package.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/packages/other/package.json @@ -2,6 +2,7 @@ "name": "other", "scripts": { "check-tty": "check-tty", - "check-tty-cached": "check-tty" + "check-tty-cached": "check-tty", + "read-stdin": "read-stdin" } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots.toml new file mode 100644 index 00000000..2bc3ee00 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots.toml @@ -0,0 +1,52 @@ +# Tests stdio behavior in interleaved mode (default --log mode). +# +# In interleaved mode: +# - cache off → all stdio inherited (stdin from parent, stdout/stderr to terminal) +# - cache on → stdin is /dev/null, stdout/stderr are piped (for capture/replay) +# +# This applies identically regardless of task count. +# +# `check-tty` prints whether each stdio fd is a TTY. +# `read-stdin` reads one line from stdin and prints it. + +# ─── stdout/stderr: cache off → inherited (tty) ───────────────── + +[[e2e]] +name = "single task, cache off, inherits stdio" +steps = ["vt run check-tty"] + +[[e2e]] +name = "multiple tasks, cache off, inherit stdio" +steps = ["vt run -r check-tty"] + +# ─── stdout/stderr: cache on, miss → piped (not-tty) ──────────── + +[[e2e]] +name = "single task, cache miss, piped stdio" +steps = ["vt run check-tty-cached"] + +[[e2e]] +name = "multiple tasks, cache miss, piped stdio" +steps = ["vt run -r check-tty-cached"] + +# ─── stdout/stderr: cache on, hit → replayed ──────────────────── + +[[e2e]] +name = "single task, cache hit, replayed" +steps = ["vt run check-tty-cached", "vt run check-tty-cached"] + +[[e2e]] +name = "multiple tasks, cache hit, replayed" +steps = ["vt run -r check-tty-cached", "vt run -r check-tty-cached"] + +# ─── stdin: cache off → inherited ─────────────────────────────── + +[[e2e]] +name = "cache off inherits stdin" +steps = ["echo from-stdin | vt run read-stdin"] + +# ─── stdin: cache on → null ───────────────────────────────────── + +[[e2e]] +name = "cache on gets null stdin" +steps = ["echo from-stdin | vt run read-stdin-cached"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/single task no cache inherits stdin.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/cache off inherits stdin.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/single task no cache inherits stdin.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/cache off inherits stdin.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/single task with cache gets null stdin.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/cache on gets null stdin.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/single task with cache gets null stdin.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/cache on gets null stdin.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache hit.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache hit, replayed.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache hit.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache hit, replayed.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache miss.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache miss, piped stdio.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache miss.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache miss, piped stdio.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache disabled.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache off, inherit stdio.snap similarity index 87% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache disabled.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache off, inherit stdio.snap index 6ff4287b..15c5f70a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/multiple tasks, cache disabled.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/multiple tasks, cache off, inherit stdio.snap @@ -9,9 +9,9 @@ stdout:not-tty stderr:not-tty $ check-tty ⊘ cache disabled -stdin:not-tty -stdout:not-tty -stderr:not-tty +stdin:tty +stdout:tty +stderr:tty --- vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/single task, cache hit.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/single task, cache hit, replayed.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/single task, cache hit.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/single task, cache hit, replayed.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/single task, cache miss.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/single task, cache miss, piped stdio.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/single task, cache miss.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/single task, cache miss, piped stdio.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/single task, cache disabled.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/single task, cache off, inherits stdio.snap similarity index 100% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots/single task, cache disabled.snap rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/snapshots/single task, cache off, inherits stdio.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/vite-task.json similarity index 54% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/vite-task.json rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/vite-task.json index cdd05d51..938ac52e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interleaved-stdio/vite-task.json @@ -8,6 +8,14 @@ "check-tty-cached": { "command": "check-tty", "cache": true + }, + "read-stdin": { + "command": "read-stdin", + "cache": false + }, + "read-stdin-cached": { + "command": "read-stdin", + "cache": true } } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/package.json new file mode 100644 index 00000000..ee07db4a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/package.json @@ -0,0 +1,4 @@ +{ + "name": "labeled-stdio-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/packages/other/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/packages/other/package.json new file mode 100644 index 00000000..2821c718 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/packages/other/package.json @@ -0,0 +1,8 @@ +{ + "name": "other", + "scripts": { + "check-tty": "check-tty", + "check-tty-cached": "check-tty", + "read-stdin": "read-stdin" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots.toml new file mode 100644 index 00000000..5524d8f7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots.toml @@ -0,0 +1,42 @@ +# Tests stdio behavior in labeled mode (--log=labeled). +# +# In labeled mode, stdio is always piped regardless of cache state: +# - stdin is /dev/null +# - stdout/stderr are piped through a line-prefixing writer ([pkg#task]) +# +# `check-tty` prints whether each stdio fd is a TTY. +# `read-stdin` reads one line from stdin and prints it. + +# ─── stdout/stderr: always piped (not-tty) ──────────────────── + +[[e2e]] +name = "single task, cache off, piped stdio" +steps = ["vt run --log=labeled check-tty"] + +[[e2e]] +name = "multiple tasks, cache off, piped stdio" +steps = ["vt run --log=labeled -r check-tty"] + +[[e2e]] +name = "single task, cache miss, piped stdio" +steps = ["vt run --log=labeled check-tty-cached"] + +[[e2e]] +name = "multiple tasks, cache miss, piped stdio" +steps = ["vt run --log=labeled -r check-tty-cached"] + +# ─── cache hit → replayed with labels ───────────────────────── + +[[e2e]] +name = "single task, cache hit, replayed" +steps = ["vt run --log=labeled check-tty-cached", "vt run --log=labeled check-tty-cached"] + +[[e2e]] +name = "multiple tasks, cache hit, replayed" +steps = ["vt run --log=labeled -r check-tty-cached", "vt run --log=labeled -r check-tty-cached"] + +# ─── stdin: always null ─────────────────────────────────────── + +[[e2e]] +name = "stdin is always null" +steps = ["echo from-stdin | vt run --log=labeled read-stdin"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache hit, replayed.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache hit, replayed.snap new file mode 100644 index 00000000..694e0489 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache hit, replayed.snap @@ -0,0 +1,30 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=labeled -r check-tty-cached +[other#check-tty-cached] ~/packages/other$ check-tty +[other#check-tty-cached] stdin:not-tty +[other#check-tty-cached] stdout:not-tty +[other#check-tty-cached] stderr:not-tty + +[labeled-stdio-test#check-tty-cached] $ check-tty +[labeled-stdio-test#check-tty-cached] stdin:not-tty +[labeled-stdio-test#check-tty-cached] stdout:not-tty +[labeled-stdio-test#check-tty-cached] stderr:not-tty + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) +> vt run --log=labeled -r check-tty-cached +[other#check-tty-cached] ~/packages/other$ check-tty ◉ cache hit, replaying +[other#check-tty-cached] stdin:not-tty +[other#check-tty-cached] stdout:not-tty +[other#check-tty-cached] stderr:not-tty + +[labeled-stdio-test#check-tty-cached] $ check-tty ◉ cache hit, replaying +[labeled-stdio-test#check-tty-cached] stdin:not-tty +[labeled-stdio-test#check-tty-cached] stdout:not-tty +[labeled-stdio-test#check-tty-cached] stderr:not-tty + +--- +vt run: 2/2 cache hit (100%), saved. (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache miss, piped stdio.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache miss, piped stdio.snap new file mode 100644 index 00000000..e82b26ec --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache miss, piped stdio.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=labeled -r check-tty-cached +[other#check-tty-cached] ~/packages/other$ check-tty +[other#check-tty-cached] stdin:not-tty +[other#check-tty-cached] stdout:not-tty +[other#check-tty-cached] stderr:not-tty + +[labeled-stdio-test#check-tty-cached] $ check-tty +[labeled-stdio-test#check-tty-cached] stdin:not-tty +[labeled-stdio-test#check-tty-cached] stdout:not-tty +[labeled-stdio-test#check-tty-cached] stderr:not-tty + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache off, piped stdio.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache off, piped stdio.snap new file mode 100644 index 00000000..ec446475 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/multiple tasks, cache off, piped stdio.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=labeled -r check-tty +[other#check-tty] ~/packages/other$ check-tty +[other#check-tty] stdin:not-tty +[other#check-tty] stdout:not-tty +[other#check-tty] stderr:not-tty + +[labeled-stdio-test#check-tty] $ check-tty ⊘ cache disabled +[labeled-stdio-test#check-tty] stdin:not-tty +[labeled-stdio-test#check-tty] stdout:not-tty +[labeled-stdio-test#check-tty] stderr:not-tty + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache hit, replayed.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache hit, replayed.snap new file mode 100644 index 00000000..a9b29445 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache hit, replayed.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=labeled check-tty-cached +[labeled-stdio-test#check-tty-cached] $ check-tty +[labeled-stdio-test#check-tty-cached] stdin:not-tty +[labeled-stdio-test#check-tty-cached] stdout:not-tty +[labeled-stdio-test#check-tty-cached] stderr:not-tty +> vt run --log=labeled check-tty-cached +[labeled-stdio-test#check-tty-cached] $ check-tty ◉ cache hit, replaying +[labeled-stdio-test#check-tty-cached] stdin:not-tty +[labeled-stdio-test#check-tty-cached] stdout:not-tty +[labeled-stdio-test#check-tty-cached] stderr:not-tty + +--- +vt run: cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache miss, piped stdio.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache miss, piped stdio.snap new file mode 100644 index 00000000..40929dc4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache miss, piped stdio.snap @@ -0,0 +1,9 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=labeled check-tty-cached +[labeled-stdio-test#check-tty-cached] $ check-tty +[labeled-stdio-test#check-tty-cached] stdin:not-tty +[labeled-stdio-test#check-tty-cached] stdout:not-tty +[labeled-stdio-test#check-tty-cached] stderr:not-tty diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache off, piped stdio.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache off, piped stdio.snap new file mode 100644 index 00000000..94a7069a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/single task, cache off, piped stdio.snap @@ -0,0 +1,9 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run --log=labeled check-tty +[labeled-stdio-test#check-tty] $ check-tty ⊘ cache disabled +[labeled-stdio-test#check-tty] stdin:not-tty +[labeled-stdio-test#check-tty] stdout:not-tty +[labeled-stdio-test#check-tty] stderr:not-tty diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/stdin is always null.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/stdin is always null.snap new file mode 100644 index 00000000..f1e1fdb8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/snapshots/stdin is always null.snap @@ -0,0 +1,6 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo from-stdin | vt run --log=labeled read-stdin +[labeled-stdio-test#read-stdin] $ read-stdin ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/vite-task.json similarity index 50% rename from crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/vite-task.json rename to crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/vite-task.json index f22a1dc9..938ac52e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/labeled-stdio/vite-task.json @@ -5,17 +5,17 @@ "command": "check-tty", "cache": false }, - "bar": { + "check-tty-cached": { "command": "check-tty", - "cache": false + "cache": true }, - "foo": { - "command": "check-tty && vt run bar && vt run -r check-tty", + "read-stdin": { + "command": "read-stdin", "cache": false }, - "foo-nested": { - "command": "vt run bar", - "cache": false + "read-stdin-cached": { + "command": "read-stdin", + "cache": true } } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/package.json deleted file mode 100644 index 9d985bfb..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "stdin-inheritance-test", - "private": true -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/pnpm-workspace.yaml deleted file mode 100644 index 924b55f4..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots.toml deleted file mode 100644 index c4d7b6f8..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots.toml +++ /dev/null @@ -1,26 +0,0 @@ -# Tests stdin inheritance behavior for spawned tasks. -# -# Stdin is inherited from the parent process only when BOTH conditions are met: -# 1. The reporter suggests inherited stdin (the leaf is in a single-node graph chain, -# or the execution uses PlainReporter for synthetic execution) -# 2. Caching is disabled for the task (cache_metadata is None) -# -# Otherwise, stdin is set to /dev/null. - -[[e2e]] -name = "single task no cache inherits stdin" -# Single spawn leaf + cache disabled → stdin is inherited -# The piped "from-stdin" should appear in the task's output -steps = ["echo from-stdin | vt run read-stdin"] - -[[e2e]] -name = "single task with cache gets null stdin" -# Single spawn leaf + cache enabled → stdin is null (protects cache determinism) -# The piped "from-stdin" should NOT appear in the task's output -steps = ["echo from-stdin | vt run read-stdin-cached"] - -[[e2e]] -name = "multiple tasks get null stdin" -# Multiple spawn leaves → stdin is null regardless of cache setting -# The piped "from-stdin" should NOT appear in any task's output -steps = ["echo from-stdin | vt run -r read-stdin"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/multiple tasks get null stdin.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/multiple tasks get null stdin.snap deleted file mode 100644 index fcb59106..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdin-inheritance/snapshots/multiple tasks get null stdin.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs ---- -> echo from-stdin | vt run -r read-stdin -~/packages/other$ read-stdin - -$ read-stdin ⊘ cache disabled - ---- -vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/package.json deleted file mode 100644 index 6de4ed0e..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "stdio-detection-test", - "private": true -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/pnpm-workspace.yaml deleted file mode 100644 index 924b55f4..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots.toml deleted file mode 100644 index 4c67e887..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-detection/snapshots.toml +++ /dev/null @@ -1,43 +0,0 @@ -# Tests stdio mode detection across all combinations of task count and cache state. -# -# `check-tty` prints whether each stdio stream is a TTY. In the PTY test -# environment, inherited stdio shows as "tty"; piped/null shows as "not-tty". -# -# Only one combination produces inherited (TTY) stdio: a single task with -# caching disabled. All other combinations use piped stdio — either because -# multiple tasks require labeled output, or because caching needs to capture -# or replay output. - -# ─── Single task ───────────────────────────────────────────────── - -[[e2e]] -name = "single task, cache disabled" -# Expect: all stdio inherited from terminal (tty) -steps = ["vt run check-tty"] - -[[e2e]] -name = "single task, cache miss" -# Expect: stdio piped for cache capture (not-tty) -steps = ["vt run check-tty-cached"] - -[[e2e]] -name = "single task, cache hit" -# Expect: first run is a miss (not-tty), second run replays cached output -steps = ["vt run check-tty-cached", "vt run check-tty-cached"] - -# ─── Multiple tasks (-r) ──────────────────────────────────────── - -[[e2e]] -name = "multiple tasks, cache disabled" -# Expect: stdio piped for labeled output (not-tty) -steps = ["vt run -r check-tty"] - -[[e2e]] -name = "multiple tasks, cache miss" -# Expect: stdio piped (not-tty) -steps = ["vt run -r check-tty-cached"] - -[[e2e]] -name = "multiple tasks, cache hit" -# Expect: first run is a miss (not-tty), second run replays cached output -steps = ["vt run -r check-tty-cached", "vt run -r check-tty-cached"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/package.json deleted file mode 100644 index 5b2b4e15..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "stdio-graph-criteria-test", - "private": true -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/packages/other/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/packages/other/package.json deleted file mode 100644 index 2b5e8f26..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/packages/other/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "other" -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/packages/other/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/packages/other/vite-task.json deleted file mode 100644 index 09c0c98b..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/packages/other/vite-task.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "tasks": { - "check-tty": { - "command": "check-tty", - "cache": false - }, - "bar": { - "command": "check-tty", - "cache": false - }, - "foo-nested": { - "command": "vt run bar", - "cache": false - } - } -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/pnpm-workspace.yaml deleted file mode 100644 index 924b55f4..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots.toml deleted file mode 100644 index 876e113a..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots.toml +++ /dev/null @@ -1,15 +0,0 @@ -# Tests stdio suggestion criteria based on graph shape along each leaf path. -# -# Rules under test: -# - Suggest piped when the leaf's containing graph has more than one node, or -# any ancestor graph on its path has more than one node. -# - Suggest inherited only when every graph on the path (root + nested Expanded -# graphs) has exactly one node. - -[[e2e]] -name = "single-node chains inherit even with multi-node sibling graph" -steps = ["vt run foo"] - -[[e2e]] -name = "multi-node ancestor forces piped for nested single-node graph" -steps = ["vt run -r foo-nested"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots/single-node chains inherit even with multi-node sibling graph.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots/single-node chains inherit even with multi-node sibling graph.snap deleted file mode 100644 index 0e80e8cf..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/stdio-graph-criteria/snapshots/single-node chains inherit even with multi-node sibling graph.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs ---- -> vt run foo -$ check-tty ⊘ cache disabled -stdin:tty -stdout:tty -stderr:tty - -$ check-tty ⊘ cache disabled -stdin:tty -stdout:tty -stderr:tty - -~/packages/other$ check-tty ⊘ cache disabled -stdin:not-tty -stdout:not-tty -stderr:not-tty - -$ check-tty ⊘ cache disabled -stdin:not-tty -stdout:not-tty -stderr:not-tty - ---- -vt run: 0/4 cache hit (0%). (Run `vt run --last-details` for full details) diff --git a/docs/stdio.md b/docs/stdio.md new file mode 100644 index 00000000..2a9d8ebf --- /dev/null +++ b/docs/stdio.md @@ -0,0 +1,104 @@ +# Task Standard I/O + +How stdin, stdout, and stderr are connected to task processes, controlled by the `--log` flag. Largely inspired by [npm-run-all2](https://github.com/bcomnes/npm-run-all2)'s stdio handling, with differences explained in the [appendix](#appendix-npm-run-all2-behavior). + +## The `--log` Flag + +``` +vp run build --log= +``` + +| Mode | Description | +| --------------------------- | ----------------------------------------------------------------------------- | +| `interleaved` **(default)** | Output streams directly to the terminal as tasks produce it. | +| `labeled` | Each line is prefixed with `[packageName#taskName]`. | +| `grouped` | Output is buffered per task and printed as a block after each task completes. | + +### Examples + +In `interleaved` mode, task names are omitted from the command line to keep output clean. In `labeled` and `grouped` modes, the `[pkg#task]` prefix is necessary to identify which task produced each line. + +#### `interleaved` + +Output goes to the terminal as soon as it's produced. When running multiple tasks in parallel, lines from different tasks may intermix: + +``` +~/packages/app$ vp dev +~/packages/docs$ vp dev + VITE v6.0.0 ready in 200 ms + + ➜ Local: http://localhost:5173/ + VITE v6.0.0 ready in 150 ms + + ➜ Local: http://localhost:5174/ +``` + +#### `labeled` + +Each line of stdout and stderr is prefixed with the task identifier. Output is still streamed as it arrives (not buffered): + +``` +[app#dev] ~/packages/app$ vp dev +[docs#dev] ~/packages/docs$ vp dev +[app#dev] VITE v6.0.0 ready in 200 ms +[app#dev] +[app#dev] ➜ Local: http://localhost:5173/ +[docs#dev] VITE v6.0.0 ready in 150 ms +[docs#dev] +[docs#dev] ➜ Local: http://localhost:5174/ +``` + +#### `grouped` + +All output (stdout and stderr) for each task is buffered and printed as a single block when the task completes. Nothing is shown for a task until it finishes: + +``` +[app#dev] ~/packages/app$ vp dev +[docs#dev] ~/packages/docs$ vp dev +── app#dev ── + VITE v6.0.0 ready in 200 ms + + ➜ Local: http://localhost:5173/ + +── docs#dev ── + VITE v6.0.0 ready in 150 ms + + ➜ Local: http://localhost:5174/ +``` + +## stdio by Mode + +| Mode | stdin | stdout | stderr | +| ------------------------ | ----------- | ---------------------------- | ---------------------------- | +| `interleaved`, cache on | `/dev/null` | Piped (streamed + collected) | Piped (streamed + collected) | +| `interleaved`, cache off | Inherited | Inherited | Inherited | +| `labeled` | `/dev/null` | Piped (prefixed + collected) | Piped (prefixed + collected) | +| `grouped` | `/dev/null` | Piped (buffered + collected) | Piped (buffered + collected) | + +### Key Rules + +1. **stdin is `/dev/null` except for uncached tasks in `interleaved` mode.** Cached tasks must have deterministic behavior — inheriting stdin would make output dependent on interactive input, breaking cache correctness. Uncached `interleaved` tasks inherit stdin, allowing interactive prompts. + +2. **stdout and stderr are piped (collected) when caching is enabled.** The collected output is stored in the cache and replayed on cache hits. In `interleaved` mode, output is still streamed to the terminal as it arrives — piping is transparent to the user. Uncached `interleaved` tasks inherit stdout/stderr directly. + +3. **stderr follows the same rules as stdout in all modes.** Unlike npm-run-all2 where `--aggregate-output` only groups stdout while inheriting stderr, `grouped` mode buffers both stdout and stderr, keeping a task's error output together with its regular output. + +## Cache Replay + +When a cached task is replayed, its stored stdout and stderr are written to the terminal using the same formatting rules as the current `--log` mode. For example, a task cached in `interleaved` mode can be replayed in `labeled` mode and will receive the appropriate prefix. + +## Appendix: npm-run-all2 Behavior + +For reference, npm-run-all2 controls stdio via two independent flags: + +| Mode | stdin | stdout | stderr | +| -------------------- | --------- | ------------------------------------------ | ------------------------------------ | +| Default | Inherited | Inherited | Inherited | +| `--print-label` | Inherited | Each line prefixed with `[taskName]` | Each line prefixed with `[taskName]` | +| `--aggregate-output` | Inherited | Grouped per task, printed after completion | Inherited (not grouped) | + +Notable differences from vite-task: + +- **stdin is always inherited.** npm-run-all2 does not have a caching system, so there is no need to prevent interactive input. +- **`--aggregate-output` only groups stdout.** stderr is inherited and streams directly to the terminal, meaning error output from parallel tasks can still intermix. vite-task's `grouped` mode buffers both streams. +- **Two separate flags** (`--print-label`, `--aggregate-output`) instead of a single `--log` enum. The flags are mutually exclusive in practice but this isn't enforced.