From 123b65e578b607900b24ec07c8b6241046ecd79f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 13:03:53 +0000 Subject: [PATCH 01/16] feat(cache): always inject FORCE_COLOR=1 and strip at writer level Spawn cached tasks with FORCE_COLOR=1 so cached output is always colored. Color-related env vars (NO_COLOR, COLORTERM, TERM, TERM_PROGRAM) are no longer passed through by default; FORCE_COLOR is pre-inserted with value "1" before pattern filtering. The reporter wraps its output writers with `anstream::StripStream` when the user's terminal doesn't support colors, so colored cache replays display correctly on plain terminals. Color support is now decided once at the CLI binary level via `supports_color::on(Stream::Stdout)` and threaded into the reporter builders as a `bool` parameter; the reporter no longer reads NO_COLOR itself. New `vtt print-color` helper and `color_env_handling` e2e fixture exercise the colour pipeline end-to-end, with a new opt-in `formatted-snapshot` mode on the e2e harness (via vt100's `Screen::contents_formatted`) for asserting ANSI bytes in cached replays. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- Cargo.lock | 3 +- Cargo.toml | 1 + crates/pty_terminal/src/terminal.rs | 11 ++ crates/pty_terminal_test/src/lib.rs | 7 + crates/vite_task/Cargo.toml | 2 + crates/vite_task/src/session/mod.rs | 57 +++++--- .../src/session/reporter/grouped/mod.rs | 14 +- .../src/session/reporter/interleaved/mod.rs | 26 +++- .../src/session/reporter/labeled/mod.rs | 22 +++- crates/vite_task/src/session/reporter/mod.rs | 104 ++++++++++++++- .../vite_task/src/session/reporter/plain.rs | 31 ++++- .../src/session/reporter/summary_reporter.rs | 12 +- crates/vite_task_bin/src/vtt/main.rs | 4 +- crates/vite_task_bin/src/vtt/print_color.rs | 34 +++++ .../fixtures/color_env_handling/package.json | 4 + .../color_env_handling/snapshots.toml | 62 +++++++++ ..._keeps_colors_across_force_color_values.md | 55 ++++++++ ...color_env_is_always_one_in_cached_child.md | 17 +++ .../color_env_handling/vite-task.json | 16 +++ .../vite_task_bin/tests/e2e_snapshots/main.rs | 58 ++++++++- crates/vite_task_graph/src/config/mod.rs | 12 +- crates/vite_task_plan/Cargo.toml | 1 - crates/vite_task_plan/src/envs.rs | 122 ++++++++---------- ...ery_tool_synthetic_task_in_user_task.jsonc | 2 +- ...query___cache_enables_script_caching.jsonc | 2 +- ...ching_even_when_cache_tasks_is_false.jsonc | 2 +- ..._per_task_cache_true_enables_caching.jsonc | 2 +- .../query_echo_and_lint_with_extra_args.jsonc | 2 +- .../query_lint_and_echo_with_extra_args.jsonc | 2 +- .../query_normal_task_with_extra_args.jsonc | 2 +- .../query_synthetic_task_in_user_task.jsonc | 2 +- ...synthetic_task_in_user_task_with_cwd.jsonc | 2 +- ...ic_task_with_extra_args_in_user_task.jsonc | 2 +- ...query_another_task_cached_by_default.jsonc | 2 +- .../query_task_cached_by_default.jsonc | 2 +- ...script_cached_when_global_cache_true.jsonc | 2 +- ...y_task_cached_when_global_cache_true.jsonc | 2 +- ..._should_put_synthetic_task_under_cwd.jsonc | 2 +- ..._should_not_affect_expanded_task_cwd.jsonc | 2 +- ...extra_args_only_reach_requested_task.jsonc | 4 +- ...d___cache_enables_inner_task_caching.jsonc | 2 +- ...opagates_to_nested_run_without_flags.jsonc | 2 +- ...es_not_propagate_into_nested___cache.jsonc | 2 +- ...uery_shell_fallback_for_pipe_command.jsonc | 2 +- ...does_not_affect_expanded_query_tasks.jsonc | 2 +- ..._not_affect_expanded_synthetic_cache.jsonc | 2 +- ..._untrackedEnv_inherited_by_synthetic.jsonc | 2 +- ...h_cache_true_enables_synthetic_cache.jsonc | 2 +- .../query_synthetic_in_subpackage.jsonc | 2 +- 49 files changed, 578 insertions(+), 151 deletions(-) create mode 100644 crates/vite_task_bin/src/vtt/print_color.rs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/force_color_env_is_always_one_in_cached_child.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/vite-task.json diff --git a/Cargo.lock b/Cargo.lock index 179a9470e..5c645d323 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4093,6 +4093,7 @@ dependencies = [ name = "vite_task" version = "0.0.0" dependencies = [ + "anstream 0.6.21", "anyhow", "async-trait", "clap", @@ -4110,6 +4111,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "supports-color 3.0.2", "tar", "tempfile", "thiserror 2.0.18", @@ -4206,7 +4208,6 @@ dependencies = [ "sha2 0.11.0", "shell-escape", "snapshot_test", - "supports-color 3.0.2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5583f09b0..f7f77892f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ future_not_send = "allow" [workspace.dependencies] allocator-api2 = { version = "0.2.21", default-features = false, features = ["alloc", "std"] } +anstream = "0.6.21" anyhow = "1.0.98" assert2 = "0.4.0" assertables = "9.8.1" diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index b77673bb9..233c43ec7 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -128,6 +128,17 @@ impl PtyReader { self.parser.lock().unwrap().screen().contents() } + /// Returns the screen contents with inline ANSI SGR escape codes (as raw + /// bytes) — useful for snapshot tests that need to assert color/style. + /// + /// # Panics + /// + /// Panics if the parser lock is poisoned. + #[must_use] + pub fn screen_contents_formatted(&self) -> Vec { + self.parser.lock().unwrap().screen().contents_formatted() + } + /// Drains and returns all unhandled OSC sequences received since the last call. /// /// Each entry is a list of byte-vector parameters from a single OSC sequence diff --git a/crates/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index b0cbe0d17..af724599e 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -52,6 +52,13 @@ impl Reader { contents } + /// Returns the screen contents with inline ANSI SGR escape codes preserved. + /// Useful for snapshot tests that need to assert colour or style attributes. + #[must_use] + pub fn screen_contents_formatted(&self) -> Vec { + self.pty.get_ref().screen_contents_formatted() + } + /// Reads from the PTY until a milestone with the given name is encountered. /// /// Returns the terminal screen contents at the moment the milestone is detected. diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index f5d77780b..90f510572 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -12,6 +12,7 @@ rust-version.workspace = true workspace = true [dependencies] +anstream = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } wincode = { workspace = true, features = ["derive"] } @@ -28,6 +29,7 @@ rusqlite = { workspace = true, features = ["bundled"] } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } +supports-color = { workspace = true } thiserror = { workspace = true } tar = { workspace = true } tokio = { workspace = true, features = [ diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 478421976..694b7ee91 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -313,20 +313,32 @@ impl<'a> Session<'a> { 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, - )), - }; + // Detect color support once at the point where reporters are + // constructed. The reporters and their pipe writers then strip + // ANSI escapes from cached/replayed output if the terminal + // can't render them. + let color_support = stdout_supports_color(); + + let inner: Box = match run_command + .flags + .log + { + crate::cli::LogMode::Interleaved => Box::new(InterleavedReporterBuilder::new( + Arc::clone(&workspace_path), + writer, + color_support, + )), + crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new( + Arc::clone(&workspace_path), + writer, + color_support, + )), + crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new( + Arc::clone(&workspace_path), + writer, + color_support, + )), + }; let builder = Box::new(SummaryReporterBuilder::new( inner, @@ -335,6 +347,7 @@ impl<'a> Session<'a> { run_command.flags.verbose, Some(self.make_summary_writer()), self.program_name.clone(), + color_support, )); // Don't let SIGINT/CTRL_C kill the runner. Child tasks receive // the signal directly from the terminal driver and handle it @@ -668,8 +681,11 @@ impl<'a> Session<'a> { let cache = self.cache()?; // Create a plain (standalone) reporter — no graph awareness, no summary - let plain_reporter = - reporter::PlainReporter::new(silent_if_cache_hit, Box::new(std::io::stdout())); + let plain_reporter = reporter::PlainReporter::new( + silent_if_cache_hit, + Box::new(std::io::stdout()), + stdout_supports_color(), + ); // Execute the spawn directly using the free function, bypassing the graph pipeline let outcome = execute::execute_spawn( @@ -770,3 +786,12 @@ impl<'a> Session<'a> { .await } } + +/// Whether stdout supports ANSI color output for the current process. Honors +/// `NO_COLOR`/`FORCE_COLOR` and detects TTY capability via the `supports-color` +/// crate. Result is cached for the process lifetime. +fn stdout_supports_color() -> bool { + use std::sync::OnceLock; + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stdout).is_some()) +} diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs index fd1816845..5f701fd74 100644 --- a/crates/vite_task/src/session/reporter/grouped/mod.rs +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -9,7 +9,8 @@ use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, - format_command_with_cache_status, format_task_label, write_leaf_trailing_output, + format_command_with_cache_status, format_task_label, maybe_strip_writer, + write_leaf_trailing_output, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; @@ -23,8 +24,12 @@ pub struct GroupedReporterBuilder { } impl GroupedReporterBuilder { - pub fn new(workspace_path: Arc, writer: Box) -> Self { - Self { workspace_path, writer } + pub fn new( + workspace_path: Arc, + writer: Box, + color_support: bool, + ) -> Self { + Self { workspace_path, writer: maybe_strip_writer(writer, color_support) } } } @@ -152,7 +157,8 @@ mod tests { let task = spawn_task("build"); let item = &task.items[0]; - let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink()))); + let builder = + Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink()), false)); 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)); diff --git a/crates/vite_task/src/session/reporter/interleaved/mod.rs b/crates/vite_task/src/session/reporter/interleaved/mod.rs index e7f17f83b..ab899384a 100644 --- a/crates/vite_task/src/session/reporter/interleaved/mod.rs +++ b/crates/vite_task/src/session/reporter/interleaved/mod.rs @@ -8,18 +8,23 @@ use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, - write_leaf_trailing_output, + maybe_strip_writer, write_leaf_trailing_output, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; pub struct InterleavedReporterBuilder { workspace_path: Arc, writer: Box, + color_support: bool, } impl InterleavedReporterBuilder { - pub fn new(workspace_path: Arc, writer: Box) -> Self { - Self { workspace_path, writer } + pub fn new( + workspace_path: Arc, + writer: Box, + color_support: bool, + ) -> Self { + Self { workspace_path, writer: maybe_strip_writer(writer, color_support), color_support } } } @@ -28,6 +33,7 @@ impl GraphExecutionReporterBuilder for InterleavedReporterBuilder { Box::new(InterleavedGraphReporter { writer: Rc::new(RefCell::new(self.writer)), workspace_path: self.workspace_path, + color_support: self.color_support, }) } } @@ -35,6 +41,7 @@ impl GraphExecutionReporterBuilder for InterleavedReporterBuilder { struct InterleavedGraphReporter { writer: Rc>>, workspace_path: Arc, + color_support: bool, } impl GraphExecutionReporter for InterleavedGraphReporter { @@ -54,6 +61,7 @@ impl GraphExecutionReporter for InterleavedGraphReporter { workspace_path: Arc::clone(&self.workspace_path), stdio_suggestion, started: false, + color_support: self.color_support, }) } @@ -70,6 +78,7 @@ struct InterleavedLeafReporter { workspace_path: Arc, stdio_suggestion: StdioSuggestion, started: bool, + color_support: bool, } impl LeafExecutionReporter for InterleavedLeafReporter { @@ -86,8 +95,8 @@ impl LeafExecutionReporter for InterleavedLeafReporter { StdioConfig { suggestion: self.stdio_suggestion, writers: PipeWriters { - stdout_writer: Box::new(std::io::stdout()), - stderr_writer: Box::new(std::io::stderr()), + stdout_writer: maybe_strip_writer(Box::new(std::io::stdout()), self.color_support), + stderr_writer: maybe_strip_writer(Box::new(std::io::stderr()), self.color_support), }, } } @@ -126,8 +135,11 @@ mod tests { display: &ExecutionItemDisplay, leaf_kind: &LeafExecutionKind, ) -> StdioSuggestion { - let builder = - Box::new(InterleavedReporterBuilder::new(test_path(), Box::new(std::io::sink()))); + let builder = Box::new(InterleavedReporterBuilder::new( + test_path(), + Box::new(std::io::sink()), + false, + )); let mut reporter = builder.build(); let mut leaf = reporter.new_leaf_execution(display, leaf_kind); leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)).suggestion diff --git a/crates/vite_task/src/session/reporter/labeled/mod.rs b/crates/vite_task/src/session/reporter/labeled/mod.rs index 500e190ef..1f731600d 100644 --- a/crates/vite_task/src/session/reporter/labeled/mod.rs +++ b/crates/vite_task/src/session/reporter/labeled/mod.rs @@ -8,7 +8,7 @@ use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label, - write_leaf_trailing_output, + maybe_strip_writer, write_leaf_trailing_output, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; @@ -19,11 +19,16 @@ use writer::LabeledWriter; pub struct LabeledReporterBuilder { workspace_path: Arc, writer: Box, + color_support: bool, } impl LabeledReporterBuilder { - pub fn new(workspace_path: Arc, writer: Box) -> Self { - Self { workspace_path, writer } + pub fn new( + workspace_path: Arc, + writer: Box, + color_support: bool, + ) -> Self { + Self { workspace_path, writer: maybe_strip_writer(writer, color_support), color_support } } } @@ -32,6 +37,7 @@ impl GraphExecutionReporterBuilder for LabeledReporterBuilder { Box::new(LabeledGraphReporter { writer: Rc::new(RefCell::new(self.writer)), workspace_path: self.workspace_path, + color_support: self.color_support, }) } } @@ -39,6 +45,7 @@ impl GraphExecutionReporterBuilder for LabeledReporterBuilder { struct LabeledGraphReporter { writer: Rc>>, workspace_path: Arc, + color_support: bool, } impl GraphExecutionReporter for LabeledGraphReporter { @@ -52,6 +59,7 @@ impl GraphExecutionReporter for LabeledGraphReporter { display: display.clone(), workspace_path: Arc::clone(&self.workspace_path), started: false, + color_support: self.color_support, }) } @@ -67,6 +75,7 @@ struct LabeledLeafReporter { display: ExecutionItemDisplay, workspace_path: Arc, started: bool, + color_support: bool, } impl LeafExecutionReporter for LabeledLeafReporter { @@ -88,11 +97,11 @@ impl LeafExecutionReporter for LabeledLeafReporter { suggestion: StdioSuggestion::Piped, writers: PipeWriters { stdout_writer: Box::new(LabeledWriter::new( - Box::new(std::io::stdout()), + maybe_strip_writer(Box::new(std::io::stdout()), self.color_support), prefix.as_bytes().to_vec(), )), stderr_writer: Box::new(LabeledWriter::new( - Box::new(std::io::stderr()), + maybe_strip_writer(Box::new(std::io::stderr()), self.color_support), prefix.as_bytes().to_vec(), )), }, @@ -134,7 +143,8 @@ mod tests { let task = spawn_task("build"); let item = &task.items[0]; - let builder = Box::new(LabeledReporterBuilder::new(test_path(), Box::new(std::io::sink()))); + let builder = + Box::new(LabeledReporterBuilder::new(test_path(), Box::new(std::io::sink()), false)); 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)); diff --git a/crates/vite_task/src/session/reporter/mod.rs b/crates/vite_task/src/session/reporter/mod.rs index 82724ecd7..a1cbfa672 100644 --- a/crates/vite_task/src/session/reporter/mod.rs +++ b/crates/vite_task/src/session/reporter/mod.rs @@ -30,7 +30,7 @@ mod plain; pub mod summary; mod summary_reporter; -use std::{io::Write, process::ExitStatus as StdExitStatus, sync::LazyLock}; +use std::{io::Write, process::ExitStatus as StdExitStatus}; pub use grouped::GroupedReporterBuilder; pub use interleaved::InterleavedReporterBuilder; @@ -105,6 +105,17 @@ pub struct PipeWriters { pub stderr_writer: Box, } +/// Wrap a writer with [`anstream::StripStream`] when `color_support` is +/// `false`. Used by reporter builders to ensure ANSI escape sequences emitted +/// by the reporter or by spawned tasks are stripped at display time when the +/// user's terminal cannot render them. +/// +/// [`anstream::StripStream`] is incremental: a single escape sequence split +/// across multiple `write` calls is still removed correctly. +pub(super) fn maybe_strip_writer(writer: Box, color_support: bool) -> Box { + if color_support { writer } else { Box::new(anstream::StripStream::new(writer)) } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Typestate traits // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -182,16 +193,16 @@ pub trait LeafExecutionReporter { // Shared display helpers // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -/// Wrap of `OwoColorize` that ignores style if `NO_COLOR` is set. +/// Re-export of `owo_colors`'s [`Styled`] applier. The reporter unconditionally +/// emits ANSI escape sequences via this trait; whether they reach the terminal +/// is decided by the writer layer (see [`maybe_strip_writer`]). trait ColorizeExt { fn style(&self, style: Style) -> Styled<&Self>; } impl ColorizeExt for T { fn style(&self, style: Style) -> Styled<&Self> { - static NO_COLOR: LazyLock = - LazyLock::new(|| std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())); - owo_colors::OwoColorize::style(self, if *NO_COLOR { Style::new() } else { style }) + owo_colors::OwoColorize::style(self, style) } } @@ -301,6 +312,89 @@ fn format_cache_hit_message() -> Str { vite_str::format!("{}\n", "◉ cache hit, logs replayed".style(Style::new().green().dimmed())) } +#[cfg(test)] +mod strip_writer_tests { + use std::io::Write; + + use super::maybe_strip_writer; + + /// Collect every byte written to an inner `Vec` via a wrapping writer. + /// Helper used to inspect what `maybe_strip_writer` actually emitted. + struct SharedSink(std::rc::Rc>>); + + impl Write for SharedSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.borrow_mut().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + fn captured(color_support: bool, chunks: &[&[u8]]) -> Vec { + let sink: std::rc::Rc>> = std::rc::Rc::default(); + let mut writer = + maybe_strip_writer(Box::new(SharedSink(std::rc::Rc::clone(&sink))), color_support); + for chunk in chunks { + writer.write_all(chunk).unwrap(); + } + writer.flush().unwrap(); + drop(writer); + sink.take() + } + + #[test] + fn keeps_ansi_when_color_supported() { + let bytes = captured(true, &[b"\x1b[31mred\x1b[0m"]); + assert_eq!(bytes, b"\x1b[31mred\x1b[0m"); + } + + #[test] + fn strips_ansi_in_single_write() { + let bytes = captured(false, &[b"\x1b[31mred\x1b[0m plain"]); + assert_eq!(bytes, b"red plain"); + } + + #[test] + fn strips_ansi_across_write_split_at_csi() { + // `\x1b[` arrives, then the rest of the SGR. + let bytes = captured(false, &[b"hello \x1b[", b"31mWORLD\x1b[0m tail"]); + assert_eq!(bytes, b"hello WORLD tail"); + } + + #[test] + fn strips_ansi_across_write_split_inside_params() { + // Split inside the parameter section of a CSI SGR. + let bytes = captured(false, &[b"\x1b[3", b"8;5;208m", b"orange\x1b[0m"]); + assert_eq!(bytes, b"orange"); + } + + #[test] + fn strips_ansi_across_write_split_byte_by_byte() { + // Worst case: one byte per write. + let escape = b"\x1b[31mhi\x1b[0m"; + let chunks: Vec<&[u8]> = escape.iter().map(std::slice::from_ref).collect(); + let bytes = captured(false, &chunks); + assert_eq!(bytes, b"hi"); + } + + #[test] + fn strips_osc_hyperlink_across_writes() { + // OSC 8 hyperlink sequence ESC ] 8 ; ; URL ESC \ TEXT ESC ] 8 ; ; ESC \ + let bytes = + captured(false, &[b"\x1b]8;;https://example.com\x1b\\", b"link", b"\x1b]8;;\x1b\\"]); + assert_eq!(bytes, b"link"); + } + + #[test] + fn leaves_plain_bytes_alone_when_stripping() { + let bytes = captured(false, &[b"plain text\n", b"another line\n"]); + assert_eq!(bytes, b"plain text\nanother line\n"); + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Test fixtures (shared by child module tests) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task/src/session/reporter/plain.rs b/crates/vite_task/src/session/reporter/plain.rs index ce7b7abc4..a58dc4b19 100644 --- a/crates/vite_task/src/session/reporter/plain.rs +++ b/crates/vite_task/src/session/reporter/plain.rs @@ -7,7 +7,7 @@ use std::io::Write; use super::{ LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_cache_hit_message, - format_error_message, + format_error_message, maybe_strip_writer, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; @@ -30,6 +30,11 @@ pub struct PlainReporter { silent_if_cache_hit: bool, /// Whether the current execution is a cache hit, set by `start()`. is_cache_hit: bool, + /// Whether the user's terminal supports ANSI colors. When false, the + /// reporter wraps its output writers with [`anstream::StripStream`] so + /// captured (cached) output and reporter messages are stripped before + /// reaching the terminal. + color_support: bool, } impl PlainReporter { @@ -37,8 +42,14 @@ impl PlainReporter { /// /// - `silent_if_cache_hit`: If true, suppress all output when the execution is a cache hit. /// - `writer`: Writer for reporter display output. - pub fn new(silent_if_cache_hit: bool, writer: Box) -> Self { - Self { writer, silent_if_cache_hit, is_cache_hit: false } + /// - `color_support`: Whether the user's terminal supports ANSI colors. + pub fn new(silent_if_cache_hit: bool, writer: Box, color_support: bool) -> Self { + Self { + writer: maybe_strip_writer(writer, color_support), + silent_if_cache_hit, + is_cache_hit: false, + color_support, + } } /// Returns true if output should be suppressed for this execution. @@ -71,8 +82,14 @@ impl LeafExecutionReporter for PlainReporter { StdioConfig { suggestion: StdioSuggestion::Inherited, writers: PipeWriters { - stdout_writer: Box::new(std::io::stdout()), - stderr_writer: Box::new(std::io::stderr()), + stdout_writer: maybe_strip_writer( + Box::new(std::io::stdout()), + self.color_support, + ), + stderr_writer: maybe_strip_writer( + Box::new(std::io::stderr()), + self.color_support, + ), }, } } @@ -109,7 +126,7 @@ mod tests { #[test] fn plain_reporter_always_suggests_inherited() { - let mut reporter = PlainReporter::new(false, Box::new(std::io::sink())); + let mut reporter = PlainReporter::new(false, Box::new(std::io::sink()), false); let stdio_config = reporter.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); assert_eq!(stdio_config.suggestion, StdioSuggestion::Inherited); @@ -117,7 +134,7 @@ mod tests { #[test] fn plain_reporter_suggests_inherited_even_when_silent() { - let mut reporter = PlainReporter::new(true, Box::new(std::io::sink())); + let mut reporter = PlainReporter::new(true, Box::new(std::io::sink()), false); let stdio_config = reporter.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); assert_eq!(stdio_config.suggestion, StdioSuggestion::Inherited); diff --git a/crates/vite_task/src/session/reporter/summary_reporter.rs b/crates/vite_task/src/session/reporter/summary_reporter.rs index 06d9f9752..997b3128c 100644 --- a/crates/vite_task/src/session/reporter/summary_reporter.rs +++ b/crates/vite_task/src/session/reporter/summary_reporter.rs @@ -12,7 +12,7 @@ use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, - StdioConfig, + StdioConfig, maybe_strip_writer, }; use crate::session::{ event::{CacheStatus, CacheUpdateStatus, ExecutionError}, @@ -43,8 +43,16 @@ impl SummaryReporterBuilder { show_details: bool, write_summary: Option, program_name: Str, + color_support: bool, ) -> Self { - Self { inner, workspace_path, writer, show_details, write_summary, program_name } + Self { + inner, + workspace_path, + writer: maybe_strip_writer(writer, color_support), + show_details, + write_summary, + program_name, + } } } diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index 66fbcba0a..d6dcb1af7 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -15,6 +15,7 @@ mod list_dir; mod mkdir; mod pipe_stdin; mod print; +mod print_color; mod print_cwd; mod print_env; mod print_file; @@ -29,7 +30,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, list-dir, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" ); std::process::exit(1); } @@ -50,6 +51,7 @@ fn main() { print::run(&args[2..]); Ok(()) } + "print-color" => print_color::run(&args[2..]), "print-cwd" => print_cwd::run(), "print-env" => print_env::run(&args[2..]), "print-file" => print_file::run(&args[2..]), diff --git a/crates/vite_task_bin/src/vtt/print_color.rs b/crates/vite_task_bin/src/vtt/print_color.rs new file mode 100644 index 000000000..47641247f --- /dev/null +++ b/crates/vite_task_bin/src/vtt/print_color.rs @@ -0,0 +1,34 @@ +//! `vtt print-color ...` — prints `text` wrapped in an ANSI SGR +//! escape sequence when `FORCE_COLOR` is set to a non-zero value, otherwise +//! prints plain text. Used by e2e tests to verify color-env handling. + +pub fn run(args: &[String]) -> Result<(), Box> { + if args.len() < 2 { + return Err("Usage: vtt print-color ...".into()); + } + let color = args[0].as_str(); + let text = args[1..].join(" "); + + let force_color = std::env::var("FORCE_COLOR").ok(); + let want_color = match force_color.as_deref() { + Some("" | "0") | None => false, + Some(_) => true, + }; + + let code: u8 = match color { + "red" => 31, + "green" => 32, + "yellow" => 33, + "blue" => 34, + "magenta" => 35, + "cyan" => 36, + other => return Err(format!("Unknown color: {other}").into()), + }; + + if want_color { + println!("\x1b[{code}m{text}\x1b[0m"); + } else { + println!("{text}"); + } + Ok(()) +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/package.json new file mode 100644 index 000000000..cf301ba7f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/package.json @@ -0,0 +1,4 @@ +{ + "name": "color-env-handling", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml new file mode 100644 index 000000000..ee965b859 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml @@ -0,0 +1,62 @@ +[[e2e]] +name = "force_color_env_is_always_one_in_cached_child" +comment = """ +The parent shell sets `FORCE_COLOR=0`, but a cached task's child always sees +`FORCE_COLOR=1`. Color-related env vars are not passed through by default for +cached tasks — the runner injects `FORCE_COLOR=1` so cached output is always +colored, and the reporter strips colors at display time when the terminal +does not support them. (For uncached tasks the parent's environment flows +through untouched.) +""" +steps = [ + { argv = [ + "vt", + "run", + "print-force-color", + ], envs = [ + [ + "FORCE_COLOR", + "0", + ], + ], comment = "parent FORCE_COLOR=0, cached child should still see FORCE_COLOR=1" }, +] + +[[e2e]] +name = "cached_output_keeps_colors_across_force_color_values" +comment = """ +A task that emits ANSI escape sequences is run twice. Each cache entry +carries the raw coloured bytes — the runner spawns cached tasks with +`FORCE_COLOR=1` regardless of the parent's value — and the reporter +strips colours at the writer level only when the user's terminal cannot +render them. + +Plain-text snapshot steps use the default `vt100::Screen::contents()` +renderer, which flattens any rendered ANSI styling into plain characters +(so colors don't pollute snapshots). The two `formatted-snapshot = true` +steps switch to `contents_formatted()`, which re-emits the screen with +inline SGR escapes (rendered as `\\\\e[…m` for review readability). Those +two steps prove that the cached bytes contained colour all along — even +when the corresponding initial run had displayed them in plain text. +""" + +[[e2e.steps]] +argv = ["vt", "run", "print-colored-a"] +envs = [["FORCE_COLOR", "1"]] +comment = "task A — cache miss; parent FORCE_COLOR=1; formatted snapshot proves the child emitted ANSI codes" +formatted-snapshot = true + +[[e2e.steps]] +argv = ["vt", "run", "print-colored-a"] +envs = [["FORCE_COLOR", "0"]] +comment = "task A — cache hit replayed; plain snapshot collapses any colour styling to text" + +[[e2e.steps]] +argv = ["vt", "run", "print-colored-b"] +envs = [["FORCE_COLOR", "0"]] +comment = "task B — cache miss; parent FORCE_COLOR=0, plain snapshot collapses styling. The cache still records the coloured bytes." + +[[e2e.steps]] +argv = ["vt", "run", "print-colored-b"] +envs = [["FORCE_COLOR", "1"]] +comment = "task B — cache hit replayed; formatted snapshot proves the cached bytes were coloured, even though the cache-miss run (above) showed them as plain text" +formatted-snapshot = true diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md new file mode 100644 index 000000000..cd1ac4c7a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md @@ -0,0 +1,55 @@ +# cached_output_keeps_colors_across_force_color_values + +A task that emits ANSI escape sequences is run twice. Each cache entry +carries the raw coloured bytes — the runner spawns cached tasks with +`FORCE_COLOR=1` regardless of the parent's value — and the reporter +strips colours at the writer level only when the user's terminal cannot +render them. + +Plain-text snapshot steps use the default `vt100::Screen::contents()` +renderer, which flattens any rendered ANSI styling into plain characters +(so colors don't pollute snapshots). The two `formatted-snapshot = true` +steps switch to `contents_formatted()`, which re-emits the screen with +inline SGR escapes (rendered as `\\e[…m` for review readability). Those +two steps prove that the cached bytes contained colour all along — even +when the corresponding initial run had displayed them in plain text. + +## `FORCE_COLOR=1 vt run print-colored-a` + +task A — cache miss; parent FORCE_COLOR=1; formatted snapshot proves the child emitted ANSI codes + +``` +\e[?25h\e[m\e[H\e[J\e[34m$ vtt print-color red hello-world\r +\e[31mhello-world\e[5;1H\e[m +``` + +## `FORCE_COLOR=0 vt run print-colored-a` + +task A — cache hit replayed; plain snapshot collapses any colour styling to text + +``` +$ vtt print-color red hello-world ◉ cache hit, replaying +hello-world + +--- +vt run: cache hit. +``` + +## `FORCE_COLOR=0 vt run print-colored-b` + +task B — cache miss; parent FORCE_COLOR=0, plain snapshot collapses styling. The cache still records the coloured bytes. + +``` +$ vtt print-color blue hello-again +hello-again +``` + +## `FORCE_COLOR=1 vt run print-colored-b` + +task B — cache hit replayed; formatted snapshot proves the cached bytes were coloured, even though the cache-miss run (above) showed them as plain text + +``` +\e[?25h\e[m\e[H\e[J\e[34m$ vtt print-color blue hello-again\e[m \e[32m◉\e[m \e[90mcache hit, replaying\r +\e[34mhello-again\e[4;1H\e[90m---\r +\e[34;1mvt run:\e[m cache hit, \e[32;1m\e[m saved.\e[7;1H +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/force_color_env_is_always_one_in_cached_child.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/force_color_env_is_always_one_in_cached_child.md new file mode 100644 index 000000000..2fb658289 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/force_color_env_is_always_one_in_cached_child.md @@ -0,0 +1,17 @@ +# force_color_env_is_always_one_in_cached_child + +The parent shell sets `FORCE_COLOR=0`, but a cached task's child always sees +`FORCE_COLOR=1`. Color-related env vars are not passed through by default for +cached tasks — the runner injects `FORCE_COLOR=1` so cached output is always +colored, and the reporter strips colors at display time when the terminal +does not support them. (For uncached tasks the parent's environment flows +through untouched.) + +## `FORCE_COLOR=0 vt run print-force-color` + +parent FORCE_COLOR=0, cached child should still see FORCE_COLOR=1 + +``` +$ vtt print-env FORCE_COLOR +1 +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/vite-task.json new file mode 100644 index 000000000..3ba4b0d82 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/vite-task.json @@ -0,0 +1,16 @@ +{ + "tasks": { + "print-force-color": { + "command": "vtt print-env FORCE_COLOR", + "cache": true + }, + "print-colored-a": { + "command": "vtt print-color red hello-world", + "cache": true + }, + "print-colored-b": { + "command": "vtt print-color blue hello-again", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index fb578a496..19cc30977 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -46,6 +46,11 @@ struct StepConfig { envs: Vec<(Str, Str)>, #[serde(default)] interactions: Vec, + /// When true, render the terminal snapshot with inline ANSI escape codes + /// (made visible as `\e[…m`) so colour/style attributes are part of the + /// assertion. Default `false` keeps the plain-text behaviour. + #[serde(default, rename = "formatted-snapshot")] + formatted_snapshot: bool, } impl Step { @@ -108,6 +113,13 @@ impl Step { Self::Simple(_) => &[], } } + + const fn formatted_snapshot(&self) -> bool { + match self { + Self::Detailed(config) => config.formatted_snapshot, + Self::Simple(_) => false, + } + } } #[derive(serde::Deserialize, Debug, Clone)] @@ -254,6 +266,35 @@ fn resolve_env_placeholder(raw: &str) -> std::borrow::Cow<'_, OsStr> { } } +/// Render a vt100 `contents_formatted` byte stream into a snapshot-friendly +/// string. Escape (`\x1b`), carriage return (`\r`), bell (`\x07`) and +/// other ASCII control bytes are made visible as `\\e`, `\\r`, etc., so the +/// markdown snapshot remains UTF-8 text and review diffs stay readable. +#[expect(clippy::disallowed_types, reason = "String required for snapshot rendering")] +fn render_formatted_screen(bytes: &[u8]) -> String { + // vt100 emits valid UTF-8 except for the escape sequences themselves; treat + // those (and other low control bytes) as opaque markers and pass through + // the rest as UTF-8. + let text = std::string::String::from_utf8_lossy(bytes); + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '\x1b' => out.push_str("\\e"), + '\n' => out.push('\n'), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\x07' => out.push_str("\\a"), + '\x08' => out.push_str("\\b"), + c if (c as u32) < 0x20 || c == '\x7f' => { + use std::fmt::Write as _; + let _ = write!(&mut out, "\\x{:02x}", c as u32); + } + c => out.push(c), + } + } + out +} + /// Append a fenced markdown block containing `body`. The opening and closing /// fences sit on their own lines, and trailing whitespace inside `body` is /// trimmed so the close fence isn't preceded by blank lines. @@ -352,8 +393,14 @@ fn run_case( } cmd.env_clear(); cmd.env("PATH", &e2e_env_path); - cmd.env("NO_COLOR", "1"); - cmd.env("TERM", "dumb"); + // Use `xterm-256color` and report color support so the runner does + // NOT wrap its output writers with [`anstream::StripStream`]. The + // strip layer would otherwise eat OSC8 milestone sequences that + // the test harness relies on to synchronise with the child. ANSI + // escapes emitted by the runner still get flattened to plain text + // by vt100's `Screen::contents()` when the snapshot is rendered, + // so this does not introduce colour-noise into existing snapshots. + cmd.env("TERM", "xterm-256color"); // On Windows, ensure common executable extensions are included in PATHEXT for command resolution in subprocesses. if cfg!(windows) { cmd.env("PATHEXT", ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC"); @@ -367,6 +414,7 @@ fn run_case( let terminal = TestTerminal::spawn(SCREEN_SIZE, cmd).unwrap(); let mut killer = terminal.child_handle.clone(); let interactions = step.interactions().to_vec(); + let formatted_snapshot = step.formatted_snapshot(); let output = Arc::new(Mutex::new(String::new())); let output_for_thread = Arc::clone(&output); let (tx, rx) = mpsc::channel(); @@ -421,7 +469,11 @@ fn run_case( } let status = terminal.reader.wait_for_exit().unwrap(); - let screen = terminal.reader.screen_contents(); + let screen = if formatted_snapshot { + render_formatted_screen(&terminal.reader.screen_contents_formatted()) + } else { + terminal.reader.screen_contents() + }; { let mut output = output_for_thread.lock().unwrap(); diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 4ab8231bd..4a165dcb8 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -421,12 +421,16 @@ pub const DEFAULT_UNTRACKED_ENV: &[&str] = &[ "DYLD_INSERT_LIBRARIES", "LIBPATH", // Terminal/display - "COLORTERM", - "TERM", - "TERM_PROGRAM", + // + // The only color-related var allowed through by default is `FORCE_COLOR`, + // which the planner pre-injects with value `1` before env resolution so + // cached output is always colored. The reporter strips colors at the + // writer level when the user's terminal cannot render them. Other + // color-related vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) + // are intentionally NOT included — users may opt in to passing them + // through via a task's `env`/`untrackedEnv` config. "DISPLAY", "FORCE_COLOR", - "NO_COLOR", // Temporary directories "TMP", "TEMP", diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index f63fec687..31290667d 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -22,7 +22,6 @@ rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } sha2 = { workspace = true } shell-escape = { workspace = true } -supports-color = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } vite_glob = { workspace = true } diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index ab6c39efd..12d5fdce4 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -3,7 +3,6 @@ use std::{collections::BTreeMap, ffi::OsStr, fmt::Write as _, mem::MaybeUninit, use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; -use supports_color::{Stream, on}; use vite_glob::GlobPatternSet; use vite_str::Str; use vite_task_graph::config::EnvConfig; @@ -76,45 +75,27 @@ impl EnvFingerprints { /// /// Before the call, `all_envs` is expected to contain all available envs. /// After the call, it will be modified to contain only envs to be passed to the execution (fingerprinted + untracked). + /// + /// `FORCE_COLOR` is pre-inserted with value `"1"` so cached output is + /// always colored. Because `FORCE_COLOR` is part of `DEFAULT_UNTRACKED_ENV`, + /// the pattern filter below keeps it; its value (`"1"`) is left untracked + /// (not part of the cache fingerprint). pub fn resolve( all_envs: &mut FxHashMap, Arc>, env_config: &EnvConfig, ) -> Result { - // Collect all envs matching fingerprinted or untracked envs in env_config - *all_envs = { - let mut new_all_envs = resolve_envs_with_patterns( - all_envs.iter(), - &env_config - .untracked_env - .iter() - .map(std::convert::AsRef::as_ref) - .chain(env_config.fingerprinted_envs.iter().map(std::convert::AsRef::as_ref)) - .collect::>(), - )?; + all_envs.insert(OsStr::new("FORCE_COLOR").into(), Arc::::from(OsStr::new("1"))); - // Automatically add FORCE_COLOR environment variable if not already set - // This enables color output in subprocesses when color is supported - // TODO: will remove this temporarily until we have a better solution - if !all_envs.contains_key(OsStr::new("FORCE_COLOR")) - && !all_envs.contains_key(OsStr::new("NO_COLOR")) - && let Some(support) = on(Stream::Stdout) - { - let force_color_value = if support.has_16m { - "3" // True color (16 million colors) - } else if support.has_256 { - "2" // 256 colors - } else if support.has_basic { - "1" // Basic ANSI colors - } else { - "0" // No color support - }; - new_all_envs.insert( - OsStr::new("FORCE_COLOR").into(), - Arc::::from(OsStr::new(force_color_value)), - ); - } - new_all_envs - }; + // Collect all envs matching fingerprinted or untracked envs in env_config + *all_envs = resolve_envs_with_patterns( + all_envs.iter(), + &env_config + .untracked_env + .iter() + .map(std::convert::AsRef::as_ref) + .chain(env_config.fingerprinted_envs.iter().map(std::convert::AsRef::as_ref)) + .collect::>(), + )?; // Resolve fingerprinted envs let mut fingerprinted_envs = BTreeMap::>::new(); @@ -242,50 +223,57 @@ mod tests { } #[test] - fn test_force_color_auto_detection() { - // Test when FORCE_COLOR is not already set - let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin")]); - let env_config = create_env_config(&[], &["PATH"]); - - let result = EnvFingerprints::resolve(&mut all_envs, &env_config).unwrap(); - - // FORCE_COLOR should be automatically added if color is supported - // Note: This test might vary based on the test environment - let force_color_present = all_envs.contains_key(OsStr::new("FORCE_COLOR")); - if force_color_present { - let force_color_value = all_envs.get(OsStr::new("FORCE_COLOR")).unwrap(); - let force_color_str = force_color_value.to_str().unwrap(); - // Should be a valid FORCE_COLOR level - assert!(matches!(force_color_str, "0" | "1" | "2" | "3")); - } - - // Test when FORCE_COLOR is already set - should not be overridden + fn test_force_color_always_set_to_one() { + // `FORCE_COLOR=1` is pre-injected before pattern filtering so cached + // output is always colored. Because the merged untracked-env list + // (config resolution adds DEFAULT_UNTRACKED_ENV, which includes + // `FORCE_COLOR`) keeps it, the child sees `FORCE_COLOR=1` regardless + // of the parent's value. let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin"), ("FORCE_COLOR", "2")]); let env_config = create_env_config(&[], &["PATH", "FORCE_COLOR"]); let _result = EnvFingerprints::resolve(&mut all_envs, &env_config).unwrap(); - // Should contain the original FORCE_COLOR value - assert!(all_envs.contains_key(OsStr::new("FORCE_COLOR"))); - let force_color_value = all_envs.get(OsStr::new("FORCE_COLOR")).unwrap(); - assert_eq!(force_color_value.to_str().unwrap(), "2"); - - // FORCE_COLOR should not be in fingerprinted_envs since it's not declared - assert!(!result.fingerprinted_envs.contains_key("FORCE_COLOR")); + let force_color_value = all_envs + .get(OsStr::new("FORCE_COLOR")) + .expect("FORCE_COLOR should be present after resolution"); + assert_eq!(force_color_value.to_str().unwrap(), "1"); + } - // Test when NO_COLOR is already set - FORCE_COLOR should not be automatically added - let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin"), ("NO_COLOR", "1")]); - let env_config = create_env_config(&[], &["PATH", "NO_COLOR"]); + #[test] + fn test_force_color_dropped_when_pattern_does_not_allow_it() { + // The resolver itself only pre-injects; it does not force-keep + // `FORCE_COLOR` through the filter. Real callers always provide + // patterns that include `FORCE_COLOR` (via `DEFAULT_UNTRACKED_ENV`), + // but this test pins the contract: if `FORCE_COLOR` is absent from + // the merged pattern list, the filter drops it. + let mut all_envs = create_test_envs(vec![("PATH", "/usr/bin")]); + let env_config = create_env_config(&[], &["PATH"]); let _result = EnvFingerprints::resolve(&mut all_envs, &env_config).unwrap(); - assert!(all_envs.contains_key(OsStr::new("NO_COLOR"))); - let no_color_value = all_envs.get(OsStr::new("NO_COLOR")).unwrap(); - assert_eq!(no_color_value.to_str().unwrap(), "1"); - // FORCE_COLOR should not be automatically added since NO_COLOR is set assert!(!all_envs.contains_key(OsStr::new("FORCE_COLOR"))); } + #[test] + fn test_force_color_value_one_overrides_user_fingerprinted_value() { + // A user can list `FORCE_COLOR` as a fingerprinted env, but the + // pre-injection still wins — fingerprint records `"1"`, not the + // parent's value. (`FORCE_COLOR` is the colour-pipeline contract; + // users wanting a different colour level should configure the tool + // they're running, not the runner.) + let mut all_envs = create_test_envs(vec![("FORCE_COLOR", "3")]); + let env_config = create_env_config(&["FORCE_COLOR"], &[]); + + let result = EnvFingerprints::resolve(&mut all_envs, &env_config).unwrap(); + + assert_eq!(all_envs.get(OsStr::new("FORCE_COLOR")).unwrap().to_str().unwrap(), "1"); + assert_eq!( + result.fingerprinted_envs.get("FORCE_COLOR").map(std::convert::AsRef::as_ref), + Some("1") + ); + } + #[test] #[cfg(unix)] fn test_task_envs_stable_ordering() { diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional_env/snapshots/query_tool_synthetic_task_in_user_task.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional_env/snapshots/query_tool_synthetic_task_in_user_task.jsonc index 63bad854a..463724f0c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional_env/snapshots/query_tool_synthetic_task_in_user_task.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional_env/snapshots/query_tool_synthetic_task_in_user_task.jsonc @@ -74,7 +74,7 @@ "TEST_VAR" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:", "TEST_VAR": "hello_world" }, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_script_caching.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_script_caching.jsonc index 63272b8d8..a5691b143 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_script_caching.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_script_caching.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_task_caching_even_when_cache_tasks_is_false.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_task_caching_even_when_cache_tasks_is_false.jsonc index af84a0ef1..30c866edb 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_task_caching_even_when_cache_tasks_is_false.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_enables_task_caching_even_when_cache_tasks_is_false.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_on_task_with_per_task_cache_true_enables_caching.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_on_task_with_per_task_cache_true_enables_caching.jsonc index 44c82a701..47e5b0324 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_on_task_with_per_task_cache_true_enables_caching.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_cli_override/snapshots/query___cache_on_task_with_per_task_cache_true_enables_caching.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_echo_and_lint_with_extra_args.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_echo_and_lint_with_extra_args.jsonc index f589579d4..48ad4b955 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_echo_and_lint_with_extra_args.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_echo_and_lint_with_extra_args.jsonc @@ -102,7 +102,7 @@ "--fix" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_lint_and_echo_with_extra_args.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_lint_and_echo_with_extra_args.jsonc index 586844bfc..4ba219e17 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_lint_and_echo_with_extra_args.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_lint_and_echo_with_extra_args.jsonc @@ -72,7 +72,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_normal_task_with_extra_args.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_normal_task_with_extra_args.jsonc index 07660f771..c838f9435 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_normal_task_with_extra_args.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_normal_task_with_extra_args.jsonc @@ -74,7 +74,7 @@ "a.txt" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task.jsonc index a75b9dfc1..4a8e20b3b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task.jsonc @@ -72,7 +72,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task_with_cwd.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task_with_cwd.jsonc index a75b9dfc1..4a8e20b3b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task_with_cwd.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_in_user_task_with_cwd.jsonc @@ -72,7 +72,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_with_extra_args_in_user_task.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_with_extra_args_in_user_task.jsonc index 71683ee96..def52132d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_with_extra_args_in_user_task.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_keys/snapshots/query_synthetic_task_with_extra_args_in_user_task.jsonc @@ -76,7 +76,7 @@ "--fix" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_another_task_cached_by_default.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_another_task_cached_by_default.jsonc index b344b082b..ae61543a1 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_another_task_cached_by_default.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_another_task_cached_by_default.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_task_cached_by_default.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_task_cached_by_default.jsonc index 78a406fa1..fd3f1f354 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_task_cached_by_default.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_scripts_task_override/snapshots/query_task_cached_by_default.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_script_cached_when_global_cache_true.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_script_cached_when_global_cache_true.jsonc index a82d5e2f9..0e38dd63c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_script_cached_when_global_cache_true.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_script_cached_when_global_cache_true.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_task_cached_when_global_cache_true.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_task_cached_when_global_cache_true.jsonc index a16181471..88f1e516f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_task_cached_when_global_cache_true.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache_true_no_force_enable/snapshots/query_task_cached_when_global_cache_true.jsonc @@ -72,7 +72,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_lint_should_put_synthetic_task_under_cwd.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_lint_should_put_synthetic_task_under_cwd.jsonc index d226a413b..93e467496 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_lint_should_put_synthetic_task_under_cwd.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_lint_should_put_synthetic_task_under_cwd.jsonc @@ -72,7 +72,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/src" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_run_should_not_affect_expanded_task_cwd.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_run_should_not_affect_expanded_task_cwd.jsonc index 4142b0960..838e481e0 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_run_should_not_affect_expanded_task_cwd.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd_in_scripts/snapshots/query_cd_before_vt_run_should_not_affect_expanded_task_cwd.jsonc @@ -98,7 +98,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/extra_args_not_forwarded_to_depends_on/snapshots/query_extra_args_only_reach_requested_task.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/extra_args_not_forwarded_to_depends_on/snapshots/query_extra_args_only_reach_requested_task.jsonc index da9501116..65177af19 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/extra_args_not_forwarded_to_depends_on/snapshots/query_extra_args_only_reach_requested_task.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/extra_args_not_forwarded_to_depends_on/snapshots/query_extra_args_only_reach_requested_task.jsonc @@ -72,7 +72,7 @@ "build" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" @@ -160,7 +160,7 @@ "some-filter" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_nested___cache_enables_inner_task_caching.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_nested___cache_enables_inner_task_caching.jsonc index 759b8f84e..9b2bd1fc8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_nested___cache_enables_inner_task_caching.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_nested___cache_enables_inner_task_caching.jsonc @@ -98,7 +98,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___cache_propagates_to_nested_run_without_flags.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___cache_propagates_to_nested_run_without_flags.jsonc index a4f58176f..c069f63c4 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___cache_propagates_to_nested_run_without_flags.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___cache_propagates_to_nested_run_without_flags.jsonc @@ -98,7 +98,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___no_cache_does_not_propagate_into_nested___cache.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___no_cache_does_not_propagate_into_nested___cache.jsonc index 6e384f70a..8d6f1a58a 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___no_cache_does_not_propagate_into_nested___cache.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested_cache_override/snapshots/query_outer___no_cache_does_not_propagate_into_nested___cache.jsonc @@ -98,7 +98,7 @@ "package.json" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_pipe_command.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_pipe_command.jsonc index b3da6fda6..37f7a9b0f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_pipe_command.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_pipe_command.jsonc @@ -72,7 +72,7 @@ "echo hello | node -e \"process.stdin.pipe(process.stdout)\"" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_parent_cache_false_does_not_affect_expanded_query_tasks.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_parent_cache_false_does_not_affect_expanded_query_tasks.jsonc index 2df26b1a1..e5fab7d13 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_parent_cache_false_does_not_affect_expanded_query_tasks.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_parent_cache_false_does_not_affect_expanded_query_tasks.jsonc @@ -98,7 +98,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_script_cache_false_does_not_affect_expanded_synthetic_cache.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_script_cache_false_does_not_affect_expanded_synthetic_cache.jsonc index d5e183324..847792c4d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_script_cache_false_does_not_affect_expanded_synthetic_cache.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_script_cache_false_does_not_affect_expanded_synthetic_cache.jsonc @@ -98,7 +98,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_untrackedEnv_inherited_by_synthetic.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_untrackedEnv_inherited_by_synthetic.jsonc index 812734076..24c7eb377 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_untrackedEnv_inherited_by_synthetic.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_untrackedEnv_inherited_by_synthetic.jsonc @@ -73,7 +73,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_with_cache_true_enables_synthetic_cache.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_with_cache_true_enables_synthetic_cache.jsonc index 9ccca7a11..a96337e79 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_with_cache_true_enables_synthetic_cache.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_cache_disabled/snapshots/query_task_with_cache_true_enables_synthetic_cache.jsonc @@ -72,7 +72,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/node_modules/.bin:" }, "cwd": "/" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_in_subpackage/snapshots/query_synthetic_in_subpackage.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_in_subpackage/snapshots/query_synthetic_in_subpackage.jsonc index 5a40fe048..5e47fdc2c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_in_subpackage/snapshots/query_synthetic_in_subpackage.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic_in_subpackage/snapshots/query_synthetic_in_subpackage.jsonc @@ -98,7 +98,7 @@ "lint" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/packages/a/node_modules/.bin:/node_modules/.bin:" }, "cwd": "/packages/a" From 92de5b89db815061192d193710149335657d78f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 13:15:46 +0000 Subject: [PATCH 02/16] refactor(e2e): use std::ascii::escape_default for formatted snapshot Replace the hand-rolled escape table in `render_formatted_screen` with a straight pass through `std::ascii::escape_default`, keeping only the newline as a special case so snapshots remain multi-line in markdown. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- .../color_env_handling/snapshots.toml | 10 ++++--- ..._keeps_colors_across_force_color_values.md | 20 +++++++------ .../vite_task_bin/tests/e2e_snapshots/main.rs | 30 ++++++------------- 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml index ee965b859..48094f6c9 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml @@ -33,10 +33,12 @@ render them. Plain-text snapshot steps use the default `vt100::Screen::contents()` renderer, which flattens any rendered ANSI styling into plain characters (so colors don't pollute snapshots). The two `formatted-snapshot = true` -steps switch to `contents_formatted()`, which re-emits the screen with -inline SGR escapes (rendered as `\\\\e[…m` for review readability). Those -two steps prove that the cached bytes contained colour all along — even -when the corresponding initial run had displayed them in plain text. +steps switch to `contents_formatted()`, then route every non-newline byte +through `std::ascii::escape_default` so escape sequences appear as +`\\xNN`. Newlines are kept literal so the snapshot stays multi-line. +Those two steps prove that the cached bytes contained colour all along +— even when the corresponding initial run had displayed them in plain +text. """ [[e2e.steps]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md index cd1ac4c7a..30c8c2ea2 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md @@ -9,18 +9,20 @@ render them. Plain-text snapshot steps use the default `vt100::Screen::contents()` renderer, which flattens any rendered ANSI styling into plain characters (so colors don't pollute snapshots). The two `formatted-snapshot = true` -steps switch to `contents_formatted()`, which re-emits the screen with -inline SGR escapes (rendered as `\\e[…m` for review readability). Those -two steps prove that the cached bytes contained colour all along — even -when the corresponding initial run had displayed them in plain text. +steps switch to `contents_formatted()`, then route every non-newline byte +through `std::ascii::escape_default` so escape sequences appear as +`\xNN`. Newlines are kept literal so the snapshot stays multi-line. +Those two steps prove that the cached bytes contained colour all along +— even when the corresponding initial run had displayed them in plain +text. ## `FORCE_COLOR=1 vt run print-colored-a` task A — cache miss; parent FORCE_COLOR=1; formatted snapshot proves the child emitted ANSI codes ``` -\e[?25h\e[m\e[H\e[J\e[34m$ vtt print-color red hello-world\r -\e[31mhello-world\e[5;1H\e[m +\x1b[?25h\x1b[m\x1b[H\x1b[J\x1b[34m$ vtt print-color red hello-world\r +\x1b[31mhello-world\x1b[5;1H\x1b[m ``` ## `FORCE_COLOR=0 vt run print-colored-a` @@ -49,7 +51,7 @@ hello-again task B — cache hit replayed; formatted snapshot proves the cached bytes were coloured, even though the cache-miss run (above) showed them as plain text ``` -\e[?25h\e[m\e[H\e[J\e[34m$ vtt print-color blue hello-again\e[m \e[32m◉\e[m \e[90mcache hit, replaying\r -\e[34mhello-again\e[4;1H\e[90m---\r -\e[34;1mvt run:\e[m cache hit, \e[32;1m\e[m saved.\e[7;1H +\x1b[?25h\x1b[m\x1b[H\x1b[J\x1b[34m$ vtt print-color blue hello-again\x1b[m \x1b[32m\xe2\x97\x89\x1b[m \x1b[90mcache hit, replaying\r +\x1b[34mhello-again\x1b[4;1H\x1b[90m---\r +\x1b[34;1mvt run:\x1b[m cache hit, \x1b[32;1m\x1b[m saved.\x1b[7;1H ``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 19cc30977..14b7802e2 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -267,29 +267,17 @@ fn resolve_env_placeholder(raw: &str) -> std::borrow::Cow<'_, OsStr> { } /// Render a vt100 `contents_formatted` byte stream into a snapshot-friendly -/// string. Escape (`\x1b`), carriage return (`\r`), bell (`\x07`) and -/// other ASCII control bytes are made visible as `\\e`, `\\r`, etc., so the -/// markdown snapshot remains UTF-8 text and review diffs stay readable. +/// string by feeding every byte through [`std::ascii::escape_default`]. +/// Newlines are kept literal so the snapshot stays readable in markdown; +/// other bytes outside printable ASCII (escape sequences, control characters, +/// multi-byte UTF-8) come out as `\xNN`, `\t`, etc. #[expect(clippy::disallowed_types, reason = "String required for snapshot rendering")] fn render_formatted_screen(bytes: &[u8]) -> String { - // vt100 emits valid UTF-8 except for the escape sequences themselves; treat - // those (and other low control bytes) as opaque markers and pass through - // the rest as UTF-8. - let text = std::string::String::from_utf8_lossy(bytes); - let mut out = String::with_capacity(text.len()); - for ch in text.chars() { - match ch { - '\x1b' => out.push_str("\\e"), - '\n' => out.push('\n'), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\x07' => out.push_str("\\a"), - '\x08' => out.push_str("\\b"), - c if (c as u32) < 0x20 || c == '\x7f' => { - use std::fmt::Write as _; - let _ = write!(&mut out, "\\x{:02x}", c as u32); - } - c => out.push(c), + let mut out = String::with_capacity(bytes.len()); + for &b in bytes { + match b { + b'\n' => out.push('\n'), + _ => out.extend(std::ascii::escape_default(b).map(char::from)), } } out From 6dddec9757196298849d7527cdf8a45053e9880c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 13:30:25 +0000 Subject: [PATCH 03/16] fix(e2e): use rows_formatted for cross-platform formatted snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Screen::contents_formatted` emits the full terminal state including cursor positioning (`\x1b[5;1H`), trailing `\r` and cursor-visibility sequences. The exact bytes depended on platform-specific PTY/runner behavior (Linux had `\x1b[5;1H`, macOS had `\x1b[4;1H`, Windows collapsed differently), making the new color e2e snapshot flaky. Switch `screen_contents_formatted` to `Screen::rows_formatted`, which yields per-row content with SGR attribute escapes only — no cursor moves, no screen erase. Trailing empty rows are dropped and the remaining rows are joined with `\n`. The e2e renderer then just feeds the bytes through `std::ascii::escape_default`; the hand-rolled SGR filter is gone. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/pty_terminal/src/terminal.rs | 27 ++++++++++++++++--- .../color_env_handling/snapshots.toml | 13 ++++----- ..._keeps_colors_across_force_color_values.md | 25 +++++++++-------- .../vite_task_bin/tests/e2e_snapshots/main.rs | 10 +++---- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 233c43ec7..b310e8a05 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -128,15 +128,36 @@ impl PtyReader { self.parser.lock().unwrap().screen().contents() } - /// Returns the screen contents with inline ANSI SGR escape codes (as raw - /// bytes) — useful for snapshot tests that need to assert color/style. + /// Returns the screen contents row-by-row with inline ANSI SGR escapes + /// preserved — useful for snapshot tests that need to assert colour/style. + /// + /// Rows are produced via [`vt100::Screen::rows_formatted`], which emits + /// only the SGR attribute escapes (no cursor positioning, no + /// screen-erase sequences), so the output is platform-stable. Trailing + /// fully-empty rows are dropped; remaining rows are joined with `\n`. /// /// # Panics /// /// Panics if the parser lock is poisoned. + #[expect( + clippy::significant_drop_tightening, + reason = "vt100::Screen::rows_formatted yields borrowed iterators that need the guard alive" + )] #[must_use] pub fn screen_contents_formatted(&self) -> Vec { - self.parser.lock().unwrap().screen().contents_formatted() + let guard = self.parser.lock().unwrap(); + let screen = guard.screen(); + let cols = screen.size().1; + let rows: Vec> = screen.rows_formatted(0, cols).collect(); + let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1); + let mut out = Vec::new(); + for (i, row) in rows[..last_non_empty].iter().enumerate() { + if i > 0 { + out.push(b'\n'); + } + out.extend_from_slice(row); + } + out } /// Drains and returns all unhandled OSC sequences received since the last call. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml index 48094f6c9..bdc3044eb 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots.toml @@ -33,12 +33,13 @@ render them. Plain-text snapshot steps use the default `vt100::Screen::contents()` renderer, which flattens any rendered ANSI styling into plain characters (so colors don't pollute snapshots). The two `formatted-snapshot = true` -steps switch to `contents_formatted()`, then route every non-newline byte -through `std::ascii::escape_default` so escape sequences appear as -`\\xNN`. Newlines are kept literal so the snapshot stays multi-line. -Those two steps prove that the cached bytes contained colour all along -— even when the corresponding initial run had displayed them in plain -text. +steps switch to `vt100::Screen::rows_formatted` (joined with newlines), +which preserves SGR escapes without emitting cursor positioning or other +rendering state — the latter varies across platforms and would make the +snapshot flaky. Bytes are then routed through `std::ascii::escape_default` +so escapes appear as `\\xNN`. Those two steps prove that the cached +bytes contained colour all along — even when the corresponding initial +run had displayed them in plain text. """ [[e2e.steps]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md index 30c8c2ea2..178dfce9b 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md @@ -9,20 +9,21 @@ render them. Plain-text snapshot steps use the default `vt100::Screen::contents()` renderer, which flattens any rendered ANSI styling into plain characters (so colors don't pollute snapshots). The two `formatted-snapshot = true` -steps switch to `contents_formatted()`, then route every non-newline byte -through `std::ascii::escape_default` so escape sequences appear as -`\xNN`. Newlines are kept literal so the snapshot stays multi-line. -Those two steps prove that the cached bytes contained colour all along -— even when the corresponding initial run had displayed them in plain -text. +steps switch to `vt100::Screen::rows_formatted` (joined with newlines), +which preserves SGR escapes without emitting cursor positioning or other +rendering state — the latter varies across platforms and would make the +snapshot flaky. Bytes are then routed through `std::ascii::escape_default` +so escapes appear as `\xNN`. Those two steps prove that the cached +bytes contained colour all along — even when the corresponding initial +run had displayed them in plain text. ## `FORCE_COLOR=1 vt run print-colored-a` task A — cache miss; parent FORCE_COLOR=1; formatted snapshot proves the child emitted ANSI codes ``` -\x1b[?25h\x1b[m\x1b[H\x1b[J\x1b[34m$ vtt print-color red hello-world\r -\x1b[31mhello-world\x1b[5;1H\x1b[m +\x1b[34m$ vtt print-color red hello-world +\x1b[31mhello-world ``` ## `FORCE_COLOR=0 vt run print-colored-a` @@ -51,7 +52,9 @@ hello-again task B — cache hit replayed; formatted snapshot proves the cached bytes were coloured, even though the cache-miss run (above) showed them as plain text ``` -\x1b[?25h\x1b[m\x1b[H\x1b[J\x1b[34m$ vtt print-color blue hello-again\x1b[m \x1b[32m\xe2\x97\x89\x1b[m \x1b[90mcache hit, replaying\r -\x1b[34mhello-again\x1b[4;1H\x1b[90m---\r -\x1b[34;1mvt run:\x1b[m cache hit, \x1b[32;1m\x1b[m saved.\x1b[7;1H +\x1b[34m$ vtt print-color blue hello-again\x1b[m \x1b[32m\xe2\x97\x89\x1b[m \x1b[90mcache hit, replaying +\x1b[34mhello-again + +\x1b[90m--- +\x1b[34;1mvt run:\x1b[m cache hit, \x1b[32;1m\x1b[m saved. ``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 14b7802e2..d222696cd 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -266,11 +266,11 @@ fn resolve_env_placeholder(raw: &str) -> std::borrow::Cow<'_, OsStr> { } } -/// Render a vt100 `contents_formatted` byte stream into a snapshot-friendly -/// string by feeding every byte through [`std::ascii::escape_default`]. -/// Newlines are kept literal so the snapshot stays readable in markdown; -/// other bytes outside printable ASCII (escape sequences, control characters, -/// multi-byte UTF-8) come out as `\xNN`, `\t`, etc. +/// Render the byte stream produced by `screen_contents_formatted` (which uses +/// `vt100::Screen::rows_formatted` — see [`pty_terminal`]) into a +/// snapshot-friendly string. Newlines (added as row separators by the PTY +/// helper) stay literal so the snapshot remains multi-line; SGR escapes and +/// other bytes outside printable ASCII come out as `\xNN`, `\t`, etc. #[expect(clippy::disallowed_types, reason = "String required for snapshot rendering")] fn render_formatted_screen(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len()); From 1239cc367638a1242fd9024997592cf877c8c2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 13:40:29 +0000 Subject: [PATCH 04/16] fix(e2e): strip SGR-reset escapes from formatted snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows ConPTY rewrites the byte stream that vt100 sees, eliding bare `\x1b[m` resets between styled spans and at the end of styled runs. Unix PTYs keep them, so the same screen state produced different formatted bytes per platform. Strip `\x1b[m` from each `rows_formatted` row in `screen_contents_formatted`. The non-reset SGR transitions (`\x1b[34m`, `\x1b[32m`, etc.) — which are the bytes the test actually asserts on — survive intact on every platform. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/pty_terminal/src/terminal.rs | 19 ++++++++++++++++++- ..._keeps_colors_across_force_color_values.md | 4 ++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index b310e8a05..1d33d2b14 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -136,6 +136,12 @@ impl PtyReader { /// screen-erase sequences), so the output is platform-stable. Trailing /// fully-empty rows are dropped; remaining rows are joined with `\n`. /// + /// Bare SGR-reset sequences (`\x1b[m`) are also stripped: Unix PTYs emit + /// them between styled spans and at the end of styled runs, but Windows + /// `ConPTY` consolidates the byte stream and elides those resets. Stripping + /// them produces identical output on all platforms while preserving the + /// non-reset SGR transitions that the test actually cares about. + /// /// # Panics /// /// Panics if the parser lock is poisoned. @@ -145,10 +151,21 @@ impl PtyReader { )] #[must_use] pub fn screen_contents_formatted(&self) -> Vec { + const RESET: &[u8] = b"\x1b[m"; let guard = self.parser.lock().unwrap(); let screen = guard.screen(); let cols = screen.size().1; - let rows: Vec> = screen.rows_formatted(0, cols).collect(); + let rows: Vec> = screen + .rows_formatted(0, cols) + .map(|mut row| { + while let Some(idx) = + row.windows(RESET.len()).position(|w| w == RESET) + { + row.drain(idx..idx + RESET.len()); + } + row + }) + .collect(); let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1); let mut out = Vec::new(); for (i, row) in rows[..last_non_empty].iter().enumerate() { diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md index 178dfce9b..89a6c718f 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/color_env_handling/snapshots/cached_output_keeps_colors_across_force_color_values.md @@ -52,9 +52,9 @@ hello-again task B — cache hit replayed; formatted snapshot proves the cached bytes were coloured, even though the cache-miss run (above) showed them as plain text ``` -\x1b[34m$ vtt print-color blue hello-again\x1b[m \x1b[32m\xe2\x97\x89\x1b[m \x1b[90mcache hit, replaying +\x1b[34m$ vtt print-color blue hello-again \x1b[32m\xe2\x97\x89 \x1b[90mcache hit, replaying \x1b[34mhello-again \x1b[90m--- -\x1b[34;1mvt run:\x1b[m cache hit, \x1b[32;1m\x1b[m saved. +\x1b[34;1mvt run: cache hit, \x1b[32;1m saved. ``` From 71c8c7077216359ad58f50b2c543978dcb4e4912 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 13:50:38 +0000 Subject: [PATCH 05/16] feat(reporter): detect colour support per stdio stream Reporters previously took a single `color_support: bool` derived from stdout. When stdout is redirected (non-TTY) but stderr is still a TTY, this incorrectly stripped colour from stderr too. Replace the bool with a `ColorSupport { stdout, stderr }` struct and plumb it through every reporter builder. The reporter's own writes (command lines, summaries) continue to use the stdout flag, and each pipe writer in [`InterleavedLeafReporter`]/[`LabeledLeafReporter`]/ [`PlainReporter`] now picks the flag matching its stream. The grouped reporter funnels everything into a single buffer flushed via the main writer, so it keeps using the stdout flag. [`Session`] detects each stream with its own `supports_color::on(Stream::*)` cache. Also bumps the Windows-only plan snapshot that stored `NO_COLOR: 1` (now `FORCE_COLOR: 1` after the default untracked-env list dropped the colour vars). https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/pty_terminal/src/terminal.rs | 4 +-- crates/vite_task/src/session/mod.rs | 21 +++++++++--- .../src/session/reporter/grouped/mod.rs | 16 ++++++--- .../src/session/reporter/interleaved/mod.rs | 32 +++++++++++------ .../src/session/reporter/labeled/mod.rs | 32 ++++++++++------- crates/vite_task/src/session/reporter/mod.rs | 21 ++++++++++++ .../vite_task/src/session/reporter/plain.rs | 34 +++++++++++-------- .../src/session/reporter/summary_reporter.rs | 11 +++--- .../query_dev_filter_from_root.jsonc | 2 +- .../snapshots/query_dev_in_subpackage.jsonc | 2 +- 10 files changed, 120 insertions(+), 55 deletions(-) diff --git a/crates/pty_terminal/src/terminal.rs b/crates/pty_terminal/src/terminal.rs index 1d33d2b14..164c6c186 100644 --- a/crates/pty_terminal/src/terminal.rs +++ b/crates/pty_terminal/src/terminal.rs @@ -158,9 +158,7 @@ impl PtyReader { let rows: Vec> = screen .rows_formatted(0, cols) .map(|mut row| { - while let Some(idx) = - row.windows(RESET.len()).position(|w| w == RESET) - { + while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) { row.drain(idx..idx + RESET.len()); } row diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 694b7ee91..503907321 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -12,7 +12,7 @@ use clap::Parser as _; use once_cell::sync::OnceCell; pub use reporter::ExitStatus; use reporter::{ - GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder, + ColorSupport, GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder, SummaryReporterBuilder, summary::{LastRunSummary, ReadSummaryError, format_full_summary}, }; @@ -316,8 +316,12 @@ impl<'a> Session<'a> { // Detect color support once at the point where reporters are // constructed. The reporters and their pipe writers then strip // ANSI escapes from cached/replayed output if the terminal - // can't render them. - let color_support = stdout_supports_color(); + // can't render them. Detect per-stream so a redirected stdout + // doesn't trigger stripping of an interactive stderr. + let color_support = ColorSupport { + stdout: stdout_supports_color(), + stderr: stderr_supports_color(), + }; let inner: Box = match run_command .flags @@ -684,7 +688,7 @@ impl<'a> Session<'a> { let plain_reporter = reporter::PlainReporter::new( silent_if_cache_hit, Box::new(std::io::stdout()), - stdout_supports_color(), + ColorSupport { stdout: stdout_supports_color(), stderr: stderr_supports_color() }, ); // Execute the spawn directly using the free function, bypassing the graph pipeline @@ -795,3 +799,12 @@ fn stdout_supports_color() -> bool { static CACHE: OnceLock = OnceLock::new(); *CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stdout).is_some()) } + +/// Whether stderr supports ANSI color output. Detected independently from +/// stdout so a redirected stdout (non-TTY) does not strip ANSI from a stderr +/// that is still an interactive terminal. +fn stderr_supports_color() -> bool { + use std::sync::OnceLock; + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some()) +} diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs index 5f701fd74..e6f53ab90 100644 --- a/crates/vite_task/src/session/reporter/grouped/mod.rs +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -7,7 +7,7 @@ use vite_path::AbsolutePath; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ - ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + ColorSupport, ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label, maybe_strip_writer, write_leaf_trailing_output, @@ -24,12 +24,15 @@ pub struct GroupedReporterBuilder { } impl GroupedReporterBuilder { + /// `color_support.stderr` is unused: grouped mode collapses every child + /// stream into a single buffer that is later flushed through the main + /// writer (assumed to be stdout), so the stdout flag drives stripping. pub fn new( workspace_path: Arc, writer: Box, - color_support: bool, + color_support: ColorSupport, ) -> Self { - Self { workspace_path, writer: maybe_strip_writer(writer, color_support) } + Self { workspace_path, writer: maybe_strip_writer(writer, color_support.stdout) } } } @@ -157,8 +160,11 @@ mod tests { let task = spawn_task("build"); let item = &task.items[0]; - let builder = - Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink()), false)); + let builder = Box::new(GroupedReporterBuilder::new( + test_path(), + Box::new(std::io::sink()), + ColorSupport::uniform(false), + )); 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)); diff --git a/crates/vite_task/src/session/reporter/interleaved/mod.rs b/crates/vite_task/src/session/reporter/interleaved/mod.rs index ab899384a..19345232f 100644 --- a/crates/vite_task/src/session/reporter/interleaved/mod.rs +++ b/crates/vite_task/src/session/reporter/interleaved/mod.rs @@ -6,25 +6,29 @@ use vite_path::AbsolutePath; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ - ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, - PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, - maybe_strip_writer, write_leaf_trailing_output, + ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, + format_command_with_cache_status, maybe_strip_writer, write_leaf_trailing_output, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; pub struct InterleavedReporterBuilder { workspace_path: Arc, writer: Box, - color_support: bool, + color_support: ColorSupport, } impl InterleavedReporterBuilder { pub fn new( workspace_path: Arc, writer: Box, - color_support: bool, + color_support: ColorSupport, ) -> Self { - Self { workspace_path, writer: maybe_strip_writer(writer, color_support), color_support } + Self { + workspace_path, + writer: maybe_strip_writer(writer, color_support.stdout), + color_support, + } } } @@ -41,7 +45,7 @@ impl GraphExecutionReporterBuilder for InterleavedReporterBuilder { struct InterleavedGraphReporter { writer: Rc>>, workspace_path: Arc, - color_support: bool, + color_support: ColorSupport, } impl GraphExecutionReporter for InterleavedGraphReporter { @@ -78,7 +82,7 @@ struct InterleavedLeafReporter { workspace_path: Arc, stdio_suggestion: StdioSuggestion, started: bool, - color_support: bool, + color_support: ColorSupport, } impl LeafExecutionReporter for InterleavedLeafReporter { @@ -95,8 +99,14 @@ impl LeafExecutionReporter for InterleavedLeafReporter { StdioConfig { suggestion: self.stdio_suggestion, writers: PipeWriters { - stdout_writer: maybe_strip_writer(Box::new(std::io::stdout()), self.color_support), - stderr_writer: maybe_strip_writer(Box::new(std::io::stderr()), self.color_support), + stdout_writer: maybe_strip_writer( + Box::new(std::io::stdout()), + self.color_support.stdout, + ), + stderr_writer: maybe_strip_writer( + Box::new(std::io::stderr()), + self.color_support.stderr, + ), }, } } @@ -138,7 +148,7 @@ mod tests { let builder = Box::new(InterleavedReporterBuilder::new( test_path(), Box::new(std::io::sink()), - false, + ColorSupport::uniform(false), )); let mut reporter = builder.build(); let mut leaf = reporter.new_leaf_execution(display, leaf_kind); diff --git a/crates/vite_task/src/session/reporter/labeled/mod.rs b/crates/vite_task/src/session/reporter/labeled/mod.rs index 1f731600d..0b45bf5db 100644 --- a/crates/vite_task/src/session/reporter/labeled/mod.rs +++ b/crates/vite_task/src/session/reporter/labeled/mod.rs @@ -6,9 +6,10 @@ use vite_path::AbsolutePath; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ - ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, - PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label, - maybe_strip_writer, write_leaf_trailing_output, + ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, + format_command_with_cache_status, format_task_label, maybe_strip_writer, + write_leaf_trailing_output, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; @@ -19,16 +20,20 @@ use writer::LabeledWriter; pub struct LabeledReporterBuilder { workspace_path: Arc, writer: Box, - color_support: bool, + color_support: ColorSupport, } impl LabeledReporterBuilder { pub fn new( workspace_path: Arc, writer: Box, - color_support: bool, + color_support: ColorSupport, ) -> Self { - Self { workspace_path, writer: maybe_strip_writer(writer, color_support), color_support } + Self { + workspace_path, + writer: maybe_strip_writer(writer, color_support.stdout), + color_support, + } } } @@ -45,7 +50,7 @@ impl GraphExecutionReporterBuilder for LabeledReporterBuilder { struct LabeledGraphReporter { writer: Rc>>, workspace_path: Arc, - color_support: bool, + color_support: ColorSupport, } impl GraphExecutionReporter for LabeledGraphReporter { @@ -75,7 +80,7 @@ struct LabeledLeafReporter { display: ExecutionItemDisplay, workspace_path: Arc, started: bool, - color_support: bool, + color_support: ColorSupport, } impl LeafExecutionReporter for LabeledLeafReporter { @@ -97,11 +102,11 @@ impl LeafExecutionReporter for LabeledLeafReporter { suggestion: StdioSuggestion::Piped, writers: PipeWriters { stdout_writer: Box::new(LabeledWriter::new( - maybe_strip_writer(Box::new(std::io::stdout()), self.color_support), + maybe_strip_writer(Box::new(std::io::stdout()), self.color_support.stdout), prefix.as_bytes().to_vec(), )), stderr_writer: Box::new(LabeledWriter::new( - maybe_strip_writer(Box::new(std::io::stderr()), self.color_support), + maybe_strip_writer(Box::new(std::io::stderr()), self.color_support.stderr), prefix.as_bytes().to_vec(), )), }, @@ -143,8 +148,11 @@ mod tests { let task = spawn_task("build"); let item = &task.items[0]; - let builder = - Box::new(LabeledReporterBuilder::new(test_path(), Box::new(std::io::sink()), false)); + let builder = Box::new(LabeledReporterBuilder::new( + test_path(), + Box::new(std::io::sink()), + ColorSupport::uniform(false), + )); 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)); diff --git a/crates/vite_task/src/session/reporter/mod.rs b/crates/vite_task/src/session/reporter/mod.rs index a1cbfa672..9c5445540 100644 --- a/crates/vite_task/src/session/reporter/mod.rs +++ b/crates/vite_task/src/session/reporter/mod.rs @@ -105,6 +105,27 @@ pub struct PipeWriters { pub stderr_writer: Box, } +/// Color-support decision split per output stream. Reporter builders receive +/// one of these so a non-TTY stdout doesn't accidentally strip colours from +/// a TTY stderr (or vice versa). +#[derive(Debug, Clone, Copy)] +pub struct ColorSupport { + /// Whether the reporter's stdout writer (and stdout-bound pipe writers + /// for spawned tasks) supports ANSI escapes. + pub stdout: bool, + /// Whether stderr-bound pipe writers support ANSI escapes. + pub stderr: bool, +} + +#[cfg(test)] +impl ColorSupport { + /// Treat both streams the same — only used in tests to avoid duplicating + /// field assignments. + pub(super) const fn uniform(supported: bool) -> Self { + Self { stdout: supported, stderr: supported } + } +} + /// Wrap a writer with [`anstream::StripStream`] when `color_support` is /// `false`. Used by reporter builders to ensure ANSI escape sequences emitted /// by the reporter or by spawned tasks are stripped at display time when the diff --git a/crates/vite_task/src/session/reporter/plain.rs b/crates/vite_task/src/session/reporter/plain.rs index a58dc4b19..33cfe592d 100644 --- a/crates/vite_task/src/session/reporter/plain.rs +++ b/crates/vite_task/src/session/reporter/plain.rs @@ -6,8 +6,8 @@ use std::io::Write; use super::{ - LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_cache_hit_message, - format_error_message, maybe_strip_writer, + ColorSupport, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, + format_cache_hit_message, format_error_message, maybe_strip_writer, }; use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; @@ -30,11 +30,11 @@ pub struct PlainReporter { silent_if_cache_hit: bool, /// Whether the current execution is a cache hit, set by `start()`. is_cache_hit: bool, - /// Whether the user's terminal supports ANSI colors. When false, the - /// reporter wraps its output writers with [`anstream::StripStream`] so - /// captured (cached) output and reporter messages are stripped before - /// reaching the terminal. - color_support: bool, + /// Per-stream colour support — stdout decides stripping of the reporter's + /// own writes and stdout-bound pipe output; stderr decides stripping of + /// the stderr pipe writer (kept independent so a TTY stderr doesn't get + /// stripped just because stdout is redirected). + color_support: ColorSupport, } impl PlainReporter { @@ -42,10 +42,14 @@ impl PlainReporter { /// /// - `silent_if_cache_hit`: If true, suppress all output when the execution is a cache hit. /// - `writer`: Writer for reporter display output. - /// - `color_support`: Whether the user's terminal supports ANSI colors. - pub fn new(silent_if_cache_hit: bool, writer: Box, color_support: bool) -> Self { + /// - `color_support`: Per-stream colour-support decision. + pub fn new( + silent_if_cache_hit: bool, + writer: Box, + color_support: ColorSupport, + ) -> Self { Self { - writer: maybe_strip_writer(writer, color_support), + writer: maybe_strip_writer(writer, color_support.stdout), silent_if_cache_hit, is_cache_hit: false, color_support, @@ -84,11 +88,11 @@ impl LeafExecutionReporter for PlainReporter { writers: PipeWriters { stdout_writer: maybe_strip_writer( Box::new(std::io::stdout()), - self.color_support, + self.color_support.stdout, ), stderr_writer: maybe_strip_writer( Box::new(std::io::stderr()), - self.color_support, + self.color_support.stderr, ), }, } @@ -126,7 +130,8 @@ mod tests { #[test] fn plain_reporter_always_suggests_inherited() { - let mut reporter = PlainReporter::new(false, Box::new(std::io::sink()), false); + let mut reporter = + PlainReporter::new(false, Box::new(std::io::sink()), ColorSupport::uniform(false)); let stdio_config = reporter.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); assert_eq!(stdio_config.suggestion, StdioSuggestion::Inherited); @@ -134,7 +139,8 @@ mod tests { #[test] fn plain_reporter_suggests_inherited_even_when_silent() { - let mut reporter = PlainReporter::new(true, Box::new(std::io::sink()), false); + let mut reporter = + PlainReporter::new(true, Box::new(std::io::sink()), ColorSupport::uniform(false)); let stdio_config = reporter.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)); assert_eq!(stdio_config.suggestion, StdioSuggestion::Inherited); diff --git a/crates/vite_task/src/session/reporter/summary_reporter.rs b/crates/vite_task/src/session/reporter/summary_reporter.rs index 997b3128c..44e5c34f0 100644 --- a/crates/vite_task/src/session/reporter/summary_reporter.rs +++ b/crates/vite_task/src/session/reporter/summary_reporter.rs @@ -11,8 +11,8 @@ use vite_str::Str; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ - ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, - StdioConfig, maybe_strip_writer, + ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + LeafExecutionReporter, StdioConfig, maybe_strip_writer, }; use crate::session::{ event::{CacheStatus, CacheUpdateStatus, ExecutionError}, @@ -36,6 +36,9 @@ pub struct SummaryReporterBuilder { } impl SummaryReporterBuilder { + /// `writer` is the summary output stream (assumed to be stdout, so + /// `color_support.stdout` decides stripping). The wrapped inner builder + /// owns per-stream stripping of the task-output writers. pub fn new( inner: Box, workspace_path: Arc, @@ -43,12 +46,12 @@ impl SummaryReporterBuilder { show_details: bool, write_summary: Option, program_name: Str, - color_support: bool, + color_support: ColorSupport, ) -> Self { Self { inner, workspace_path, - writer: maybe_strip_writer(writer, color_support), + writer: maybe_strip_writer(writer, color_support.stdout), show_details, write_summary, program_name, diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc index ba1f67e9e..175ac4e20 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_filter_from_root.jsonc @@ -84,7 +84,7 @@ "3000" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/packages/foo/node_modules/.bin:/node_modules/.bin:" }, "cwd": "/packages/foo" diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc index dc5f9a939..2ce0827c1 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/windows_cmd_shim_rewrite/snapshots/query_dev_in_subpackage.jsonc @@ -84,7 +84,7 @@ "3000" ], "all_envs": { - "NO_COLOR": "1", + "FORCE_COLOR": "1", "PATH": "/packages/foo/node_modules/.bin:/node_modules/.bin:" }, "cwd": "/packages/foo" From 8ae0cec79583a929bf1bc97b7ba192d19445ba8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 14:14:01 +0000 Subject: [PATCH 06/16] refactor(reporter): drop ColorizeExt indirection `ColorizeExt::style` used to short-circuit to a `Style::new()` when `NO_COLOR` was set; with stripping now handled at the writer layer the trait reduced to a pure passthrough to `OwoColorize::style`. Use `OwoColorize::style` directly at the call sites. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- .../vite_task/src/session/reporter/grouped/mod.rs | 4 ++-- crates/vite_task/src/session/reporter/mod.rs | 15 +-------------- crates/vite_task/src/session/reporter/summary.rs | 4 ++-- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs index e6f53ab90..ae4ca63ae 100644 --- a/crates/vite_task/src/session/reporter/grouped/mod.rs +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -2,12 +2,12 @@ use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; -use owo_colors::Style; +use owo_colors::{OwoColorize as _, Style}; use vite_path::AbsolutePath; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ - ColorSupport, ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label, maybe_strip_writer, write_leaf_trailing_output, diff --git a/crates/vite_task/src/session/reporter/mod.rs b/crates/vite_task/src/session/reporter/mod.rs index 9c5445540..cd6cd4646 100644 --- a/crates/vite_task/src/session/reporter/mod.rs +++ b/crates/vite_task/src/session/reporter/mod.rs @@ -35,7 +35,7 @@ use std::{io::Write, process::ExitStatus as StdExitStatus}; pub use grouped::GroupedReporterBuilder; pub use interleaved::InterleavedReporterBuilder; pub use labeled::LabeledReporterBuilder; -use owo_colors::{Style, Styled}; +use owo_colors::{OwoColorize as _, Style}; pub use plain::PlainReporter; pub use summary_reporter::SummaryReporterBuilder; use vite_path::AbsolutePath; @@ -214,19 +214,6 @@ pub trait LeafExecutionReporter { // Shared display helpers // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -/// Re-export of `owo_colors`'s [`Styled`] applier. The reporter unconditionally -/// emits ANSI escape sequences via this trait; whether they reach the terminal -/// is decided by the writer layer (see [`maybe_strip_writer`]). -trait ColorizeExt { - fn style(&self, style: Style) -> Styled<&Self>; -} - -impl ColorizeExt for T { - fn style(&self, style: Style) -> Styled<&Self> { - owo_colors::OwoColorize::style(self, style) - } -} - const COMMAND_STYLE: Style = Style::new().blue(); const CACHE_MISS_STYLE: Style = Style::new().bright_black(); diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index f1a01168f..5cd731e26 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -9,12 +9,12 @@ use std::{io::Write, num::NonZeroI32, time::Duration}; -use owo_colors::Style; +use owo_colors::{OwoColorize as _, Style}; use serde::{Deserialize, Serialize}; use vite_path::AbsolutePath; use vite_str::Str; -use super::{CACHE_MISS_STYLE, COMMAND_STYLE, ColorizeExt}; +use super::{CACHE_MISS_STYLE, COMMAND_STYLE}; use crate::session::{ cache::{ CacheMiss, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, From 75cadd6f43954b62af2411e0709588f3655f8cb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 14:36:59 +0000 Subject: [PATCH 07/16] fix(reporter): strip ANSI when rendering --last-details summary `show_last_run_details` formats the saved summary via `format_full_summary` and used to rely on the `ColorizeExt` wrapper to silently drop styles when `NO_COLOR` was set. After the wrapper was removed, the formatted buffer always carried ANSI escapes, so users with `NO_COLOR=1` or a redirected stdout would see escape codes in the saved-summary output. Route the buffer through `maybe_strip_writer(stdout_supports_color())` so the strip layer runs in the same way as the live reporter path. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/vite_task/src/session/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 503907321..846934b5a 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -610,7 +610,10 @@ impl<'a> Session<'a> { let buf = format_full_summary(&summary); { use std::io::Write; - let mut stdout = std::io::stdout().lock(); + let mut stdout = reporter::maybe_strip_writer( + Box::new(std::io::stdout()), + stdout_supports_color(), + ); stdout.write_all(&buf)?; stdout.flush()?; } From 770835382c3ad726a3771aee2e67ca02fb890f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:18:45 +0000 Subject: [PATCH 08/16] refactor(reporter): apply maybe_strip_writer only to pipe writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict `maybe_strip_writer` to the `StdioConfig.writers` returned from `LeafExecutionReporter::start` — those receive bytes the runner does not control (child stdout/stderr, cache replay) so stripping at the writer level is the only correct place. For the reporter's own writes (command banners, error lines, summary blocks, --last-details rendering) introduce a thin `ColorizeExt::style` extension that delegates to `OwoColorize::if_supports_color(Stream::Stdout, …)`. Call sites keep the existing `.style(Style::new().bold())` syntax; the result is now a no-op on terminals that lack colour support (honouring `NO_COLOR`, `FORCE_COLOR`, and TTY detection via the `supports-color` crate). For the grouped reporter, child output is buffered before display, so its pipe writers wrap `GroupedWriter` with `maybe_strip_writer` and the main writer is stored unwrapped. Plain/Interleaved/Labeled/Summary builders likewise no longer wrap their main writer. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/vite_task/src/session/mod.rs | 9 +++--- .../src/session/reporter/grouped/mod.rs | 29 ++++++++++++++----- .../src/session/reporter/interleaved/mod.rs | 11 +++---- .../src/session/reporter/labeled/mod.rs | 9 +++--- crates/vite_task/src/session/reporter/mod.rs | 23 ++++++++++++++- .../vite_task/src/session/reporter/plain.rs | 10 +++---- .../vite_task/src/session/reporter/summary.rs | 4 +-- .../src/session/reporter/summary_reporter.rs | 20 +++++-------- 8 files changed, 71 insertions(+), 44 deletions(-) diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 846934b5a..ed1fd5cb8 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -607,13 +607,14 @@ impl<'a> Session<'a> { let path = self.summary_file_path(); match LastRunSummary::read_from_path(&path) { Ok(Some(summary)) => { + // `format_full_summary` decides colour vs plain text per + // styled span via `ColorizeExt` (which consults + // `supports-color`), so the buffer already matches the + // terminal's capability and we write it to stdout directly. let buf = format_full_summary(&summary); { use std::io::Write; - let mut stdout = reporter::maybe_strip_writer( - Box::new(std::io::stdout()), - stdout_supports_color(), - ); + let mut stdout = std::io::stdout().lock(); stdout.write_all(&buf)?; stdout.flush()?; } diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs index ae4ca63ae..00f87fdc4 100644 --- a/crates/vite_task/src/session/reporter/grouped/mod.rs +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -2,12 +2,12 @@ use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc}; -use owo_colors::{OwoColorize as _, Style}; +use owo_colors::Style; use vite_path::AbsolutePath; use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ - ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, + ColorSupport, ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status, format_task_label, maybe_strip_writer, write_leaf_trailing_output, @@ -21,18 +21,21 @@ use writer::GroupedWriter; pub struct GroupedReporterBuilder { workspace_path: Arc, writer: Box, + color_support: ColorSupport, } impl GroupedReporterBuilder { - /// `color_support.stderr` is unused: grouped mode collapses every child - /// stream into a single buffer that is later flushed through the main - /// writer (assumed to be stdout), so the stdout flag drives stripping. + /// Grouped mode buffers child output and flushes it through `writer` + /// in [`Self::finish`]. The pipe writers themselves (see + /// [`Self::start`]) strip ANSI on the way into the buffer, so by the + /// time the buffer reaches `writer` it already matches the terminal's + /// colour capability. `writer` is therefore stored unwrapped. pub fn new( workspace_path: Arc, writer: Box, color_support: ColorSupport, ) -> Self { - Self { workspace_path, writer: maybe_strip_writer(writer, color_support.stdout) } + Self { workspace_path, writer, color_support } } } @@ -41,6 +44,7 @@ impl GraphExecutionReporterBuilder for GroupedReporterBuilder { Box::new(GroupedGraphReporter { writer: Rc::new(RefCell::new(self.writer)), workspace_path: self.workspace_path, + color_support: self.color_support, }) } } @@ -48,6 +52,7 @@ impl GraphExecutionReporterBuilder for GroupedReporterBuilder { struct GroupedGraphReporter { writer: Rc>>, workspace_path: Arc, + color_support: ColorSupport, } impl GraphExecutionReporter for GroupedGraphReporter { @@ -64,6 +69,7 @@ impl GraphExecutionReporter for GroupedGraphReporter { label, started: false, grouped_buffer: None, + color_support: self.color_support, }) } @@ -81,6 +87,7 @@ struct GroupedLeafReporter { label: vite_str::Str, started: bool, grouped_buffer: Option>>>, + color_support: ColorSupport, } impl LeafExecutionReporter for GroupedLeafReporter { @@ -103,8 +110,14 @@ impl LeafExecutionReporter for GroupedLeafReporter { StdioConfig { suggestion: StdioSuggestion::Piped, writers: PipeWriters { - stdout_writer: Box::new(GroupedWriter::new(Rc::clone(&buffer))), - stderr_writer: Box::new(GroupedWriter::new(buffer)), + stdout_writer: maybe_strip_writer( + Box::new(GroupedWriter::new(Rc::clone(&buffer))), + self.color_support.stdout, + ), + stderr_writer: maybe_strip_writer( + Box::new(GroupedWriter::new(buffer)), + self.color_support.stderr, + ), }, } } diff --git a/crates/vite_task/src/session/reporter/interleaved/mod.rs b/crates/vite_task/src/session/reporter/interleaved/mod.rs index 19345232f..670d9eaa7 100644 --- a/crates/vite_task/src/session/reporter/interleaved/mod.rs +++ b/crates/vite_task/src/session/reporter/interleaved/mod.rs @@ -19,16 +19,17 @@ pub struct InterleavedReporterBuilder { } impl InterleavedReporterBuilder { + /// The reporter's own writes (command lines, error banners) decide + /// colour-vs-plain at format time via [`ColorizeExt`], so `writer` is + /// stored unwrapped. `color_support` is forwarded to the pipe writers + /// in [`Self::start`], where ANSI emitted by child tasks is stripped + /// for non-terminal sinks. pub fn new( workspace_path: Arc, writer: Box, color_support: ColorSupport, ) -> Self { - Self { - workspace_path, - writer: maybe_strip_writer(writer, color_support.stdout), - color_support, - } + Self { workspace_path, writer, color_support } } } diff --git a/crates/vite_task/src/session/reporter/labeled/mod.rs b/crates/vite_task/src/session/reporter/labeled/mod.rs index 0b45bf5db..e19fc7845 100644 --- a/crates/vite_task/src/session/reporter/labeled/mod.rs +++ b/crates/vite_task/src/session/reporter/labeled/mod.rs @@ -24,16 +24,15 @@ pub struct LabeledReporterBuilder { } impl LabeledReporterBuilder { + /// `writer` is stored unwrapped — the reporter's own writes pick + /// colour-vs-plain at format time via [`ColorizeExt`]. Child-process + /// pipes are stripped per-stream inside [`Self::start`]. pub fn new( workspace_path: Arc, writer: Box, color_support: ColorSupport, ) -> Self { - Self { - workspace_path, - writer: maybe_strip_writer(writer, color_support.stdout), - color_support, - } + Self { workspace_path, writer, color_support } } } diff --git a/crates/vite_task/src/session/reporter/mod.rs b/crates/vite_task/src/session/reporter/mod.rs index cd6cd4646..d8ca219ac 100644 --- a/crates/vite_task/src/session/reporter/mod.rs +++ b/crates/vite_task/src/session/reporter/mod.rs @@ -35,7 +35,7 @@ use std::{io::Write, process::ExitStatus as StdExitStatus}; pub use grouped::GroupedReporterBuilder; pub use interleaved::InterleavedReporterBuilder; pub use labeled::LabeledReporterBuilder; -use owo_colors::{OwoColorize as _, Style}; +use owo_colors::Style; pub use plain::PlainReporter; pub use summary_reporter::SummaryReporterBuilder; use vite_path::AbsolutePath; @@ -217,6 +217,27 @@ pub trait LeafExecutionReporter { const COMMAND_STYLE: Style = Style::new().blue(); const CACHE_MISS_STYLE: Style = Style::new().bright_black(); +/// Apply `style` to `self` only when stdout supports ANSI colours +/// (auto-detected via the `supports-color` crate, honouring `NO_COLOR`, +/// `FORCE_COLOR`, and TTY). Used by the format helpers that write to the +/// reporter's main writer / saved-summary buffer; for child-process pipes +/// see [`maybe_strip_writer`] instead, which strips bytes the runner does +/// not control. +trait ColorizeExt: owo_colors::OwoColorize { + fn style(&self, style: Style) -> impl std::fmt::Display + '_; +} + +impl ColorizeExt for T +where + T: owo_colors::OwoColorize + std::fmt::Display, +{ + fn style(&self, style: Style) -> impl std::fmt::Display + '_ { + self.if_supports_color(owo_colors::Stream::Stdout, move |s| { + owo_colors::OwoColorize::style(s, style) + }) + } +} + /// Format the display's cwd as a string relative to the workspace root. /// Returns an empty string if the cwd equals the workspace root. fn format_cwd_relative(display: &ExecutionItemDisplay, workspace_path: &AbsolutePath) -> Str { diff --git a/crates/vite_task/src/session/reporter/plain.rs b/crates/vite_task/src/session/reporter/plain.rs index 33cfe592d..ad34f409a 100644 --- a/crates/vite_task/src/session/reporter/plain.rs +++ b/crates/vite_task/src/session/reporter/plain.rs @@ -9,6 +9,9 @@ use super::{ ColorSupport, LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion, format_cache_hit_message, format_error_message, maybe_strip_writer, }; +// `maybe_strip_writer` is used for the child-process pipe writers; reporter +// output decides colour-vs-plain at format time via the `ColorizeExt` helpers +// in [`super`]. use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError}; /// A self-contained [`LeafExecutionReporter`] for single-leaf executions @@ -48,12 +51,7 @@ impl PlainReporter { writer: Box, color_support: ColorSupport, ) -> Self { - Self { - writer: maybe_strip_writer(writer, color_support.stdout), - silent_if_cache_hit, - is_cache_hit: false, - color_support, - } + Self { writer, silent_if_cache_hit, is_cache_hit: false, color_support } } /// Returns true if output should be suppressed for this execution. diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 5cd731e26..f1a01168f 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -9,12 +9,12 @@ use std::{io::Write, num::NonZeroI32, time::Duration}; -use owo_colors::{OwoColorize as _, Style}; +use owo_colors::Style; use serde::{Deserialize, Serialize}; use vite_path::AbsolutePath; use vite_str::Str; -use super::{CACHE_MISS_STYLE, COMMAND_STYLE}; +use super::{CACHE_MISS_STYLE, COMMAND_STYLE, ColorizeExt}; use crate::session::{ cache::{ CacheMiss, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, diff --git a/crates/vite_task/src/session/reporter/summary_reporter.rs b/crates/vite_task/src/session/reporter/summary_reporter.rs index 44e5c34f0..8979e7786 100644 --- a/crates/vite_task/src/session/reporter/summary_reporter.rs +++ b/crates/vite_task/src/session/reporter/summary_reporter.rs @@ -12,7 +12,7 @@ use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind}; use super::{ ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, - LeafExecutionReporter, StdioConfig, maybe_strip_writer, + LeafExecutionReporter, StdioConfig, }; use crate::session::{ event::{CacheStatus, CacheUpdateStatus, ExecutionError}, @@ -36,9 +36,10 @@ pub struct SummaryReporterBuilder { } impl SummaryReporterBuilder { - /// `writer` is the summary output stream (assumed to be stdout, so - /// `color_support.stdout` decides stripping). The wrapped inner builder - /// owns per-stream stripping of the task-output writers. + /// `writer` is the summary output stream. The wrapped inner builder + /// owns per-stream stripping of the child-process pipe writers; the + /// reporter's own summary text picks colour-vs-plain at format time + /// via [`ColorizeExt`], so `writer` is stored unwrapped. pub fn new( inner: Box, workspace_path: Arc, @@ -46,16 +47,9 @@ impl SummaryReporterBuilder { show_details: bool, write_summary: Option, program_name: Str, - color_support: ColorSupport, + _color_support: ColorSupport, ) -> Self { - Self { - inner, - workspace_path, - writer: maybe_strip_writer(writer, color_support.stdout), - show_details, - write_summary, - program_name, - } + Self { inner, workspace_path, writer, show_details, write_summary, program_name } } } From f2fea5dd04c9f65f04c7fc38201c147bd8eeeeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:22:10 +0000 Subject: [PATCH 09/16] fix(reporter): use plain text for cross-file doc references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-link `[\`Self::start\`]` resolves against the builder type (which has no `start` method) and `[\`ColorizeExt\`]` is out of scope in `summary_reporter.rs`, so `cargo doc -D warnings` rejected the new comments. Demote both to plain code-fenced names — they still read clearly without the rustdoc cross-link. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/vite_task/src/session/reporter/grouped/mod.rs | 2 +- crates/vite_task/src/session/reporter/interleaved/mod.rs | 4 ++-- crates/vite_task/src/session/reporter/labeled/mod.rs | 4 ++-- crates/vite_task/src/session/reporter/summary_reporter.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs index 00f87fdc4..ed95fbb2f 100644 --- a/crates/vite_task/src/session/reporter/grouped/mod.rs +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -27,7 +27,7 @@ pub struct GroupedReporterBuilder { impl GroupedReporterBuilder { /// Grouped mode buffers child output and flushes it through `writer` /// in [`Self::finish`]. The pipe writers themselves (see - /// [`Self::start`]) strip ANSI on the way into the buffer, so by the + /// `LeafExecutionReporter::start`) strip ANSI on the way into the buffer, so by the /// time the buffer reaches `writer` it already matches the terminal's /// colour capability. `writer` is therefore stored unwrapped. pub fn new( diff --git a/crates/vite_task/src/session/reporter/interleaved/mod.rs b/crates/vite_task/src/session/reporter/interleaved/mod.rs index 670d9eaa7..9722e95e0 100644 --- a/crates/vite_task/src/session/reporter/interleaved/mod.rs +++ b/crates/vite_task/src/session/reporter/interleaved/mod.rs @@ -20,9 +20,9 @@ pub struct InterleavedReporterBuilder { impl InterleavedReporterBuilder { /// The reporter's own writes (command lines, error banners) decide - /// colour-vs-plain at format time via [`ColorizeExt`], so `writer` is + /// colour-vs-plain at format time via `ColorizeExt`, so `writer` is /// stored unwrapped. `color_support` is forwarded to the pipe writers - /// in [`Self::start`], where ANSI emitted by child tasks is stripped + /// in `LeafExecutionReporter::start`, where ANSI emitted by child tasks is stripped /// for non-terminal sinks. pub fn new( workspace_path: Arc, diff --git a/crates/vite_task/src/session/reporter/labeled/mod.rs b/crates/vite_task/src/session/reporter/labeled/mod.rs index e19fc7845..fa41fa641 100644 --- a/crates/vite_task/src/session/reporter/labeled/mod.rs +++ b/crates/vite_task/src/session/reporter/labeled/mod.rs @@ -25,8 +25,8 @@ pub struct LabeledReporterBuilder { impl LabeledReporterBuilder { /// `writer` is stored unwrapped — the reporter's own writes pick - /// colour-vs-plain at format time via [`ColorizeExt`]. Child-process - /// pipes are stripped per-stream inside [`Self::start`]. + /// colour-vs-plain at format time via `ColorizeExt`. Child-process + /// pipes are stripped per-stream inside `LeafExecutionReporter::start`. pub fn new( workspace_path: Arc, writer: Box, diff --git a/crates/vite_task/src/session/reporter/summary_reporter.rs b/crates/vite_task/src/session/reporter/summary_reporter.rs index 8979e7786..789bc29a2 100644 --- a/crates/vite_task/src/session/reporter/summary_reporter.rs +++ b/crates/vite_task/src/session/reporter/summary_reporter.rs @@ -39,7 +39,7 @@ impl SummaryReporterBuilder { /// `writer` is the summary output stream. The wrapped inner builder /// owns per-stream stripping of the child-process pipe writers; the /// reporter's own summary text picks colour-vs-plain at format time - /// via [`ColorizeExt`], so `writer` is stored unwrapped. + /// via `ColorizeExt`, so `writer` is stored unwrapped. pub fn new( inner: Box, workspace_path: Arc, From 0fefd38331b9f4c83c718f2324d72047fa5e6029 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:26:03 +0000 Subject: [PATCH 10/16] fix(reporter): demote another Self::finish doc link `[\`Self::finish\`]` on `GroupedReporterBuilder` resolves against the builder struct (which has no `finish` method), so `cargo doc -D warnings` rejected it. Replaced with prose. Re-ran `RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps` and the workspace docs build cleanly. https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- crates/vite_task/src/session/reporter/grouped/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/vite_task/src/session/reporter/grouped/mod.rs b/crates/vite_task/src/session/reporter/grouped/mod.rs index ed95fbb2f..d3c508808 100644 --- a/crates/vite_task/src/session/reporter/grouped/mod.rs +++ b/crates/vite_task/src/session/reporter/grouped/mod.rs @@ -26,10 +26,10 @@ pub struct GroupedReporterBuilder { impl GroupedReporterBuilder { /// Grouped mode buffers child output and flushes it through `writer` - /// in [`Self::finish`]. The pipe writers themselves (see - /// `LeafExecutionReporter::start`) strip ANSI on the way into the buffer, so by the - /// time the buffer reaches `writer` it already matches the terminal's - /// colour capability. `writer` is therefore stored unwrapped. + /// at finish time. The pipe writers themselves (see + /// `LeafExecutionReporter::start`) strip ANSI on the way into the buffer, + /// so by the time the buffer reaches `writer` it already matches the + /// terminal's colour capability. `writer` is therefore stored unwrapped. pub fn new( workspace_path: Arc, writer: Box, From c1ebf13afafd9cf273f5501b3344c86e2e0bbf83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:34:20 +0000 Subject: [PATCH 11/16] docs: update CHANGELOG for color env handling changes https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b702c12e9..33d443f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Changed** Cached tasks are now spawned with `FORCE_COLOR=1` so captured output is always coloured; the reporter strips colours at the writer level when the user's terminal does not support them. Color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default; tasks that need them can opt in via `env`/`untrackedEnv`. Color support is detected per stdio stream, so a redirected stdout no longer strips colour from an interactive stderr ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) - **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375)) - **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366)) - **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352)) From e6d5c87d99676e1b4c887e66f1f904bb144f5933 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:34:58 +0000 Subject: [PATCH 12/16] docs: tighten changelog entry per update-changelog skill https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d443f74..b05a2b63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Changed** Cached tasks are now spawned with `FORCE_COLOR=1` so captured output is always coloured; the reporter strips colours at the writer level when the user's terminal does not support them. Color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default; tasks that need them can opt in via `env`/`untrackedEnv`. Color support is detected per stdio stream, so a redirected stdout no longer strips colour from an interactive stderr ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) +- **Changed** Cached task output is always colored (the runner injects `FORCE_COLOR=1`) and the reporter strips colors per stdout/stderr when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default — opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) - **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375)) - **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366)) - **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352)) From 2a7716251fddc5735a80e6dc9c5502b83c38e624 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:35:45 +0000 Subject: [PATCH 13/16] docs: drop "reporter" jargon from changelog entry https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05a2b63b..6acf58cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Changed** Cached task output is always colored (the runner injects `FORCE_COLOR=1`) and the reporter strips colors per stdout/stderr when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default — opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) +- **Changed** Cached task output is always colored (`FORCE_COLOR=1` is auto-injected) and colors are stripped per stdout/stderr when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default — opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) - **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375)) - **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366)) - **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352)) From ed8188976ae33225a0bc8cee8882c0775ac11361 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:36:10 +0000 Subject: [PATCH 14/16] docs: split the dash-joined clause in the changelog entry https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acf58cbd..64e14d2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Changed** Cached task output is always colored (`FORCE_COLOR=1` is auto-injected) and colors are stripped per stdout/stderr when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default — opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) +- **Changed** Cached task output is always colored (`FORCE_COLOR=1` is auto-injected) and colors are stripped per stdout/stderr when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) - **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375)) - **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366)) - **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352)) From 77dd51cb1f0f7a5df15bb14ca0fe92b8cab9c9b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:39:06 +0000 Subject: [PATCH 15/16] docs: clarify cache stores colored output https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e14d2dd..056c461de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Changed** Cached task output is always colored (`FORCE_COLOR=1` is auto-injected) and colors are stripped per stdout/stderr when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) +- **Changed** Cached output is stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) - **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375)) - **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366)) - **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352)) From a43050c308984cfa7ef0149695ff7f4627148cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 15:41:21 +0000 Subject: [PATCH 16/16] docs: rename Cached output to Cached logs in changelog https://claude.ai/code/session_01EAhpw6TzUWExMeF7hdwuF3 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056c461de..add778222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Changed** Cached output is stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) +- **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378)) - **Added** `output` field for cached tasks: archives matching files after a successful run and restores them on cache hit ([#375](https://github.com/voidzero-dev/vite-task/pull/375)) - **Fixed** Windows cached tasks can now run package shims rewritten through PowerShell; default env passthrough now preserves `PATHEXT` ([#366](https://github.com/voidzero-dev/vite-task/pull/366)) - **Added** Platform support for targets without `input` auto-inference (e.g. Android). Tasks still run; those relying on auto-inference run uncached, with the summary noting that `input` must be configured manually to enable caching ([#352](https://github.com/voidzero-dev/vite-task/pull/352))