diff --git a/.typos.toml b/.typos.toml index 9b8271d7..f671e6db 100644 --- a/.typos.toml +++ b/.typos.toml @@ -6,4 +6,7 @@ PUNICODE = "PUNICODE" extend-exclude = [ "crates/fspy_detours_sys/detours", "crates/fspy_detours_sys/src/generated_bindings.rs", + # Intentional typos for testing fuzzy matching and "did you mean" suggestions + "crates/vite_select/src/fuzzy.rs", + "crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select", ] diff --git a/CLAUDE.md b/CLAUDE.md index 086b3e59..993281ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,8 @@ Test fixtures and snapshots: - If a feature can't work on a platform, it shouldn't be added 2. **Windows Cross-Testing from macOS**: + `cargo xtest` cross-compiles the test binary and runs it on a real remote Windows environment (not emulation). The filesystem is bridged so the test can access local fixture files. ```bash - # Test on Windows (aarch64) from macOS via cross-compilation cargo xtest --builder cargo-xwin --target aarch64-pc-windows-msvc -p --test # Examples: diff --git a/Cargo.lock b/Cargo.lock index a253c5c6..cab5bbb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,6 +1499,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1576,6 +1582,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -2009,6 +2026,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -2197,6 +2224,10 @@ name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking_lot" @@ -3182,6 +3213,16 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -3785,6 +3826,18 @@ dependencies = [ "vite_str", ] +[[package]] +name = "vite_select" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert2", + "crossterm", + "nucleo-matcher", + "owo-colors", + "vite_str", +] + [[package]] name = "vite_shell" version = "0.0.0" @@ -3825,6 +3878,7 @@ dependencies = [ "once_cell", "owo-colors", "petgraph", + "pty_terminal_test_client", "rayon", "rusqlite", "rustc-hash", @@ -3836,6 +3890,7 @@ dependencies = [ "twox-hash", "vite_glob", "vite_path", + "vite_select", "vite_str", "vite_task_graph", "vite_task_plan", @@ -3851,13 +3906,11 @@ dependencies = [ "clap", "cow-utils", "cp_r", - "crossterm", "insta", "jsonc-parser", "pathdiff", "pty_terminal", "pty_terminal_test", - "pty_terminal_test_client", "regex", "rustc-hash", "serde", @@ -3913,7 +3966,7 @@ dependencies = [ "serde_json", "sha2", "shell-escape", - "supports-color", + "supports-color 3.0.2", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 65153612..d9fcb3f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,10 +85,11 @@ memmap2 = "0.9.7" monostate = "1.0.2" nix = { version = "0.30.1", features = ["dir"] } ntapi = "0.4.1" +nucleo-matcher = "0.3.1" once_cell = "1.19" os_str_bytes = "7.1.1" ouroboros = "0.18.5" -owo-colors = "4.1.0" +owo-colors = { version = "4.1.0", features = ["supports-colors"] } passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false } pathdiff = "0.2.3" petgraph = "0.8.2" @@ -135,6 +136,7 @@ vec1 = "1.12.1" vite_glob = { path = "crates/vite_glob" } vite_graph_ser = { path = "crates/vite_graph_ser" } vite_path = { path = "crates/vite_path" } +vite_select = { path = "crates/vite_select" } vite_shell = { path = "crates/vite_shell" } vite_str = { path = "crates/vite_str" } vite_task = { path = "crates/vite_task" } diff --git a/crates/vite_select/Cargo.toml b/crates/vite_select/Cargo.toml new file mode 100644 index 00000000..bc859128 --- /dev/null +++ b/crates/vite_select/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vite_select" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +crossterm = { workspace = true } +nucleo-matcher = { workspace = true } +owo-colors = { workspace = true } +vite_str = { path = "../vite_str" } + +[dev-dependencies] +assert2 = { workspace = true } diff --git a/crates/vite_select/src/fuzzy.rs b/crates/vite_select/src/fuzzy.rs new file mode 100644 index 00000000..4fa4f4dc --- /dev/null +++ b/crates/vite_select/src/fuzzy.rs @@ -0,0 +1,104 @@ +use nucleo_matcher::{ + Matcher, + pattern::{AtomKind, CaseMatching, Normalization, Pattern}, +}; + +/// Fuzzy-match `query` against a list of strings. +/// +/// Returns original indices sorted by score descending (best match first). +/// When `query` is empty, returns all indices in their original order. +#[must_use] +pub fn fuzzy_match(query: &str, items: &[&str]) -> Vec { + if query.is_empty() { + return (0..items.len()).collect(); + } + + let pattern = Pattern::new(query, CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy); + let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT); + + let mut scored: Vec<(usize, u32)> = items + .iter() + .enumerate() + .filter_map(|(idx, item)| { + pattern + .score(nucleo_matcher::Utf32Str::Ascii(item.as_bytes()), &mut matcher) + .map(|score| (idx, score)) + }) + .collect(); + + scored.sort_by(|a, b| b.1.cmp(&a.1)); + scored.into_iter().map(|(idx, _)| idx).collect() +} + +#[cfg(test)] +mod tests { + use assert2::{assert, check}; + + use super::*; + + const TASK_NAMES: &[&str] = + &["build", "lint", "test", "app#build", "app#lint", "app#test", "lib#build"]; + + #[test] + fn exact_match_scores_highest() { + let results = fuzzy_match("build", TASK_NAMES); + assert!(!results.is_empty()); + // "build" should be the highest-scoring match + check!(TASK_NAMES[results[0]] == "build"); + } + + #[test] + fn typo_matches_similar() { + let results = fuzzy_match("buid", TASK_NAMES); + assert!(!results.is_empty()); + // Should match "build" and "app#build" and "lib#build" but not "lint" or "test" + let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect(); + check!(matched_names.contains(&"build")); + for name in &matched_names { + check!(!name.contains("lint")); + check!(!name.contains("test")); + } + } + + #[test] + fn empty_query_returns_all() { + let results = fuzzy_match("", TASK_NAMES); + check!(results.len() == TASK_NAMES.len()); + // Indices should be in original order + for (pos, &idx) in results.iter().enumerate() { + check!(idx == pos); + } + } + + #[test] + fn completely_unrelated_query_returns_nothing() { + let results = fuzzy_match("zzzzz", TASK_NAMES); + check!(results.is_empty()); + } + + #[test] + fn package_qualified_match() { + let results = fuzzy_match("app#build", TASK_NAMES); + assert!(!results.is_empty()); + check!(TASK_NAMES[results[0]] == "app#build"); + } + + #[test] + fn lint_matches_lint_tasks() { + let results = fuzzy_match("lint", TASK_NAMES); + assert!(!results.is_empty()); + let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect(); + check!(matched_names.contains(&"lint")); + check!(matched_names.contains(&"app#lint")); + } + + #[test] + fn score_ordering_exact_before_fuzzy() { + let results = fuzzy_match("build", TASK_NAMES); + assert!(results.len() >= 2); + // Exact "build" should appear before "app#build" (higher score = earlier position) + let build_pos = results.iter().position(|&i| TASK_NAMES[i] == "build").unwrap(); + let app_build_pos = results.iter().position(|&i| TASK_NAMES[i] == "app#build").unwrap(); + check!(build_pos <= app_build_pos); + } +} diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs new file mode 100644 index 00000000..b7a78e7f --- /dev/null +++ b/crates/vite_select/src/interactive.rs @@ -0,0 +1,465 @@ +use std::io::{Write, stdout}; + +use crossterm::{ + cursor::{self, MoveToColumn}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + style::{Attribute, SetAttribute}, + terminal::{self, Clear, ClearType}, +}; +use owo_colors::{OwoColorize, Stream}; + +use crate::{RenderState, SelectItem, fuzzy::fuzzy_match}; + +struct RawModeGuard; + +impl RawModeGuard { + fn enable() -> anyhow::Result { + terminal::enable_raw_mode()?; + Ok(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + } +} + +struct State<'a> { + items: &'a [SelectItem], + /// Indices into `items` that match the current query, in score order. + filtered: Vec, + #[expect( + clippy::disallowed_types, + reason = "crossterm key events push chars one at a time; String is natural here" + )] + query: String, + /// Index into `filtered`. + selected: usize, + /// First visible row in the filtered list (scroll offset). + scroll_offset: usize, + page_size: usize, + /// Number of lines rendered in the last frame (for clearing). + rendered_lines: usize, +} + +impl<'a> State<'a> { + fn new(items: &'a [SelectItem], initial_query: Option<&str>, page_size: usize) -> Self { + let query = initial_query.unwrap_or_default().to_owned(); + let mut state = Self { + items, + filtered: Vec::new(), + query, + selected: 0, + scroll_offset: 0, + page_size, + rendered_lines: 0, + }; + state.refilter(); + state + } + + fn refilter(&mut self) { + let labels: Vec<&str> = self.items.iter().map(|i| i.label.as_str()).collect(); + self.filtered = fuzzy_match(&self.query, &labels); + self.selected = 0; + self.scroll_offset = 0; + } + + const fn move_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } + } + } + + const fn move_down(&mut self) { + if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 { + self.selected += 1; + if self.selected >= self.scroll_offset + self.page_size { + self.scroll_offset = self.selected + 1 - self.page_size; + } + } + } + + fn selected_original_index(&self) -> Option { + self.filtered.get(self.selected).copied() + } + + fn visible_range(&self) -> std::ops::Range { + let end = (self.scroll_offset + self.page_size).min(self.filtered.len()); + self.scroll_offset..end + } + + const fn hidden_count(&self) -> usize { + self.filtered.len().saturating_sub(self.scroll_offset + self.page_size) + } +} + +/// Parameters for rendering a task list. +pub struct RenderParams<'a> { + pub items: &'a [SelectItem], + pub filtered: &'a [usize], + /// Index into `filtered` of the highlighted item, or `None` for non-interactive. + pub selected_in_filtered: Option, + /// Which slice of `filtered` to display. + pub visible_range: std::ops::Range, + /// Number of items beyond the visible range. + pub hidden_count: usize, + pub header: Option<&'a str>, + /// Current search text. `Some` enables the prompt line (interactive only). + pub query: Option<&'a str>, + /// `"\r\n"` for raw mode, `"\n"` for normal. + pub line_ending: &'a str, + /// Maximum visible width per line. Descriptions are truncated to prevent + /// line wrapping, which would break cursor-based clearing in interactive mode. + /// Use `usize::MAX` to disable truncation (non-interactive / piped output). + pub max_line_width: usize, +} + +/// Render the item list. Shared rendering logic used by both interactive +/// and non-interactive modes (via [`crate::non_interactive`]). +/// +/// Returns the number of lines written. +pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyhow::Result { + let RenderParams { + items, + filtered, + selected_in_filtered, + visible_range, + hidden_count, + header, + query, + line_ending, + max_line_width: _, + } = params; + + let mut lines = 0usize; + + // Header (e.g. error message) + if let Some(header) = header { + write!(writer, "{header}{line_ending}")?; + lines += 1; + } + + // Prompt line (interactive only) + if let Some(q) = query { + let bold = SetAttribute(Attribute::Bold); + let reset = SetAttribute(Attribute::Reset); + // Print ": " separator before query only when query is non-empty, + // to avoid a trailing space that Windows ConPTY would strip. + if q.is_empty() { + write!( + writer, + "{bold}Search task{reset} (\u{2191}/\u{2193} to move, enter to select):{line_ending}", + )?; + } else { + write!( + writer, + "{bold}Search task{reset} (\u{2191}/\u{2193} to move, enter to select): {q}{line_ending}", + )?; + } + lines += 1; + } + + // Items + for vi in visible_range.clone() { + let item_idx = filtered[vi]; + let item = &items[item_idx]; + let is_selected = *selected_in_filtered == Some(vi); + + // Truncate description to prevent line wrapping. + // Line layout: prefix (2: "> " or " ") + label + ": " (2) + description + let prefix_and_label_width = 2 + item.label.chars().count() + 2; + let max_desc_chars = params.max_line_width.saturating_sub(prefix_and_label_width); + let desc_str = item.description.as_str(); + let desc_char_count = desc_str.chars().count(); + let truncated; + let display_desc = if desc_char_count > max_desc_chars { + let take = max_desc_chars.saturating_sub(1); // room for "…" + #[expect(clippy::disallowed_types, reason = "intermediate collect for char truncation")] + let prefix: std::string::String = desc_str.chars().take(take).collect(); + truncated = vite_str::format!("{prefix}\u{2026}"); + truncated.as_str() + } else { + desc_str + }; + let desc = display_desc.if_supports_color(Stream::Stdout, |s| s.cyan()); + + if is_selected { + write!( + writer, + "{bold}> {label}: {desc}{reset}{line_ending}", + bold = SetAttribute(Attribute::Bold), + label = item.label, + reset = SetAttribute(Attribute::Reset), + )?; + } else { + write!(writer, " {}: {desc}{line_ending}", item.label)?; + } + lines += 1; + } + + // Footer: hidden items count + if *hidden_count > 0 { + write!(writer, " (\u{2026}{hidden_count} more){line_ending}")?; + lines += 1; + } + + // Empty state + if filtered.is_empty() { + write!(writer, " No matching tasks.{line_ending}")?; + lines += 1; + } + + writer.flush()?; + Ok(lines) +} + +fn render( + stdout: &mut impl Write, + state: &mut State<'_>, + header: Option<&str>, +) -> anyhow::Result<()> { + // Move cursor up to clear previous render + if state.rendered_lines > 0 { + let move_up = u16::try_from(state.rendered_lines) + .expect("rendered_lines fits in u16: at most header + page_size + footer lines"); + crossterm::execute!( + stdout, + cursor::MoveUp(move_up), + MoveToColumn(0), + Clear(ClearType::FromCursorDown), + )?; + } + + // Query terminal width on each render to handle resize + let max_line_width = terminal::size().map_or(80, |(w, _)| w as usize); + + let lines = render_items( + stdout, + &RenderParams { + items: state.items, + filtered: &state.filtered, + selected_in_filtered: Some(state.selected), + visible_range: state.visible_range(), + hidden_count: state.hidden_count(), + header, + query: Some(&state.query), + line_ending: "\r\n", + max_line_width, + }, + )?; + + state.rendered_lines = lines; + Ok(()) +} + +pub fn run( + items: &[SelectItem], + initial_query: Option<&str>, + selected_index: &mut usize, + header: Option<&str>, + page_size: usize, + mut before_render: impl FnMut(&mut Vec, &str), + mut after_render: impl FnMut(&RenderState<'_>), +) -> anyhow::Result<()> { + if items.is_empty() { + anyhow::bail!("No tasks available"); + } + + let _guard = RawModeGuard::enable()?; + // Hide cursor while the widget is active + let mut out = stdout(); + crossterm::execute!(out, cursor::Hide)?; + + let mut state = State::new(items, initial_query, page_size); + before_render(&mut state.filtered, &state.query); + + // Initial render + render(&mut out, &mut state, header)?; + after_render(&RenderState { query: &state.query, selected_index: state.selected }); + + loop { + let ev = event::read()?; + match ev { + Event::Key(KeyEvent { code, modifiers, kind: KeyEventKind::Press, .. }) => match code { + KeyCode::Esc => { + // Clear the search query and reset the filter + state.query.clear(); + state.refilter(); + before_render(&mut state.filtered, &state.query); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + cleanup(&mut out, &state)?; + std::process::exit(130); + } + KeyCode::Enter => { + if let Some(idx) = state.selected_original_index() { + *selected_index = idx; + } + cleanup(&mut out, &state)?; + return Ok(()); + } + KeyCode::Up => { + state.move_up(); + } + KeyCode::Down => { + state.move_down(); + } + KeyCode::Char(c) => { + state.query.push(c); + state.refilter(); + before_render(&mut state.filtered, &state.query); + } + KeyCode::Backspace => { + state.query.pop(); + state.refilter(); + before_render(&mut state.filtered, &state.query); + } + _ => continue, + }, + _ => continue, + } + + render(&mut out, &mut state, header)?; + after_render(&RenderState { query: &state.query, selected_index: state.selected }); + } +} + +/// Clear the widget output and restore cursor. +fn cleanup(stdout: &mut impl Write, state: &State<'_>) -> anyhow::Result<()> { + if state.rendered_lines > 0 { + let move_up = u16::try_from(state.rendered_lines) + .expect("rendered_lines fits in u16: at most header + page_size + footer lines"); + crossterm::execute!( + stdout, + cursor::MoveUp(move_up), + MoveToColumn(0), + Clear(ClearType::FromCursorDown), + )?; + } + crossterm::execute!(stdout, cursor::Show)?; + stdout.flush()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_items(items: &[(&str, &str)]) -> Vec { + items + .iter() + .map(|(label, desc)| SelectItem { label: (*label).into(), description: (*desc).into() }) + .collect() + } + + /// Strip ANSI escape sequences from output for easier assertions. + #[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")] + fn strip_ansi(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip until we hit a letter (end of escape sequence) + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } else { + result.push(c); + } + } + result + } + + #[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")] + fn render_to_string(items: &[SelectItem], max_line_width: usize) -> String { + let filtered: Vec = (0..items.len()).collect(); + let len = filtered.len(); + let mut buf = Vec::new(); + render_items( + &mut buf, + &RenderParams { + items, + filtered: &filtered, + selected_in_filtered: Some(0), + visible_range: 0..len, + hidden_count: 0, + header: None, + query: None, + line_ending: "\n", + max_line_width, + }, + ) + .unwrap(); + strip_ansi(&String::from_utf8(buf).unwrap()) + } + + #[test] + fn truncates_long_description() { + let items = make_items(&[("build", "a]really long command that exceeds the width limit")]); + // " build: a really long..." = 2 + 5 + 2 + desc + // max_line_width = 30 => max_desc = 30 - 9 = 21 chars + let output = render_to_string(&items, 30); + let line = output.lines().next().unwrap(); + // "> " (2) + "build" (5) + ": " (2) + desc (21) = 30 + assert!( + line.chars().count() <= 30, + "line should be at most 30 chars, got {}: {line:?}", + line.chars().count() + ); + assert!(line.contains('\u{2026}'), "truncated line should contain ellipsis: {line:?}"); + } + + #[test] + fn does_not_truncate_short_description() { + let items = make_items(&[("build", "echo ok")]); + let output = render_to_string(&items, 80); + let line = output.lines().next().unwrap(); + assert!(!line.contains('\u{2026}'), "short line should not be truncated: {line:?}"); + assert!(line.contains("echo ok"), "full description should appear: {line:?}"); + } + + #[test] + fn max_line_width_max_disables_truncation() { + let long_desc = "x".repeat(500); + let items = make_items(&[("build", &long_desc)]); + let output = render_to_string(&items, usize::MAX); + let line = output.lines().next().unwrap(); + assert!(!line.contains('\u{2026}'), "usize::MAX should disable truncation: {line:?}"); + assert!(line.contains(&long_desc), "full 500-char description should appear"); + } + + #[test] + fn each_line_fits_within_max_width() { + let items = make_items(&[ + ("build", "tsc -p tsconfig.build.json && echo done"), + ("lint", "oxlint --fix"), + ("test", "vitest run --reporter=verbose --coverage"), + ]); + let max_width = 40; + let output = render_to_string(&items, max_width); + for line in output.lines() { + assert!( + line.chars().count() <= max_width, + "line exceeds max width {max_width}: ({}) {line:?}", + line.chars().count() + ); + } + } + + #[test] + fn truncation_preserves_label() { + let items = make_items(&[("my-task", "very long description here")]); + // " my-task: very..." => prefix(2) + label(7) + sep(2) + desc + // max_line_width = 20 => max_desc = 20 - 11 = 9 chars + let output = render_to_string(&items, 20); + let line = output.lines().next().unwrap(); + assert!(line.contains("my-task"), "label should always be preserved: {line:?}"); + } +} diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs new file mode 100644 index 00000000..a2bac06b --- /dev/null +++ b/crates/vite_select/src/lib.rs @@ -0,0 +1,114 @@ +mod fuzzy; +mod interactive; + +use std::io::Write; + +pub use fuzzy::fuzzy_match; +use interactive::{RenderParams, render_items}; +use vite_str::Str; + +/// An item in the selection list. +pub struct SelectItem { + /// Display label, e.g. `"build"` or `"app#build"`. + pub label: Str, + /// Description shown next to the label, e.g. `"echo build app"`. + pub description: Str, +} + +/// Selection mode. +pub enum Mode<'a> { + /// Interactive terminal UI with fuzzy search, keyboard navigation, and selection. + /// + /// On Enter, `*selected_index` is set to the index of the chosen item + /// in the original `items` slice. + Interactive { selected_index: &'a mut usize }, + /// Non-interactive: renders the list once and returns. + NonInteractive, +} + +/// Snapshot of the selector's visible state, passed to `after_render`. +pub struct RenderState<'a> { + /// Current search text (empty if no filter typed yet). + pub query: &'a str, + /// Index of the highlighted item in the **filtered** list. + pub selected_index: usize, +} + +/// Parameters for [`select_list`]. +pub struct SelectParams<'a> { + pub items: &'a [SelectItem], + /// Initial search query (pre-filled in interactive, used as filter in non-interactive). + pub query: Option<&'a str>, + /// Header line rendered above the list (e.g. an error message). + pub header: Option<&'a str>, + /// Max visible rows (interactive only). + pub page_size: usize, +} + +/// Show a task selection list. +/// +/// In [`Mode::Interactive`], enters a terminal UI with fuzzy search and +/// keyboard navigation. `after_render` is called after every render with the +/// current visible state (useful for emitting test milestones). On Enter, +/// `*selected_index` is set to the chosen item's index in the original +/// `items` slice. +/// +/// In [`Mode::NonInteractive`], renders the list once to `writer` and +/// returns. `page_size` and `after_render` are ignored. +/// +/// # Errors +/// +/// Returns an error if terminal I/O fails. +pub fn select_list( + writer: &mut impl Write, + params: &SelectParams<'_>, + mode: Mode<'_>, + before_render: impl FnMut(&mut Vec, &str), + after_render: impl FnMut(&RenderState<'_>), +) -> anyhow::Result<()> { + match mode { + Mode::Interactive { selected_index } => interactive::run( + params.items, + params.query, + selected_index, + params.header, + params.page_size, + before_render, + after_render, + ), + Mode::NonInteractive => { + non_interactive(writer, params.items, params.query, params.header, before_render) + } + } +} + +fn non_interactive( + writer: &mut impl Write, + items: &[SelectItem], + query: Option<&str>, + header: Option<&str>, + mut before_render: impl FnMut(&mut Vec, &str), +) -> anyhow::Result<()> { + let labels: Vec<&str> = items.iter().map(|item| item.label.as_str()).collect(); + let mut filtered: Vec = + query.map_or_else(|| (0..items.len()).collect(), |q| fuzzy_match(q, &labels)); + before_render(&mut filtered, query.unwrap_or_default()); + let len = filtered.len(); + + render_items( + writer, + &RenderParams { + items, + filtered: &filtered, + selected_in_filtered: None, + visible_range: 0..len, + hidden_count: 0, + header, + query: None, + line_ending: "\n", + max_line_width: usize::MAX, + }, + )?; + + Ok(()) +} diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 9192f1e5..d485a683 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -24,6 +24,7 @@ futures-util = { workspace = true } once_cell = { workspace = true } owo-colors = { workspace = true } petgraph = { workspace = true } +pty_terminal_test_client = { workspace = true } rayon = { workspace = true } rusqlite = { workspace = true, features = ["bundled"] } rustc-hash = { workspace = true } @@ -35,6 +36,7 @@ tracing = { workspace = true } twox-hash = { workspace = true } vite_glob = { workspace = true } vite_path = { workspace = true } +vite_select = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } vite_task_plan = { workspace = true } diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 5e40ff69..45bc76d5 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -12,12 +12,12 @@ pub enum CacheSubcommand { Clean, } -/// Arguments for the `run` subcommand. -#[derive(Debug, clap::Args)] -pub struct RunCommand { - /// `packageName#taskName` or `taskName`. - pub task_specifier: TaskSpecifier, - +/// Flags that control how a `run` command selects tasks. +/// +/// Extracted as a separate struct so they can be cheaply `Copy`-ed +/// before `RunCommand` is consumed. +#[derive(Debug, Clone, Copy, clap::Args)] +pub struct RunFlags { /// Run tasks found in all packages in the workspace, in topological order based on package dependencies. #[clap(default_value = "false", short, long)] pub recursive: bool, @@ -29,6 +29,16 @@ pub struct RunCommand { /// Do not run dependencies specified in `dependsOn` fields. #[clap(default_value = "false", long)] pub ignore_depends_on: bool, +} + +/// Arguments for the `run` subcommand. +#[derive(Debug, clap::Args)] +pub struct RunCommand { + /// `packageName#taskName` or `taskName`. If omitted, lists all available tasks. + pub task_specifier: Option, + + #[clap(flatten)] + pub flags: RunFlags, /// Additional arguments to pass to the tasks #[clap(trailing_var_arg = true, allow_hyphen_values = true)] @@ -49,6 +59,9 @@ pub enum Command { #[derive(thiserror::Error, Debug)] pub enum CLITaskQueryError { + #[error("no task specifier provided")] + MissingTaskSpecifier, + #[error("--recursive and --transitive cannot be used together")] RecursiveTransitiveConflict, @@ -67,8 +80,13 @@ impl RunCommand { self, cwd: &Arc, ) -> Result { - let Self { task_specifier, recursive, transitive, ignore_depends_on, additional_args } = - self; + let Self { + task_specifier, + flags: RunFlags { recursive, transitive, ignore_depends_on }, + additional_args, + } = self; + + let task_specifier = task_specifier.ok_or(CLITaskQueryError::MissingTaskSpecifier)?; let include_explicit_deps = !ignore_depends_on; diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index bf391971..41e7c04b 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -4,7 +4,7 @@ mod maybe_str; pub mod session; // Public exports for vite_task_bin -pub use cli::{CacheSubcommand, Command, RunCommand}; +pub use cli::{CacheSubcommand, Command, RunCommand, RunFlags}; pub use session::{CommandHandler, ExitStatus, HandledCommand, Session, SessionCallbacks}; pub use vite_task_graph::{ config::{ diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 48badea9..7975a100 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -4,7 +4,7 @@ mod execute; pub(crate) mod reporter; // Re-export types that are part of the public API -use std::{ffi::OsStr, fmt::Debug, sync::Arc}; +use std::{ffi::OsStr, fmt::Debug, io::IsTerminal, sync::Arc}; use cache::ExecutionCache; pub use cache::{CacheMiss, FingerprintMismatch}; @@ -14,9 +14,10 @@ pub use reporter::ExitStatus; use reporter::LabeledReporter; use rustc_hash::FxHashMap; use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_select::SelectItem; use vite_str::Str; use vite_task_graph::{ - IndexedTaskGraph, TaskGraph, TaskGraphLoadError, config::user::UserCacheConfig, + IndexedTaskGraph, TaskGraph, TaskGraphLoadError, TaskSpecifier, config::user::UserCacheConfig, loader::UserConfigLoader, }; use vite_task_plan::{ @@ -26,7 +27,7 @@ use vite_task_plan::{ }; use vite_workspace::{WorkspaceRoot, find_workspace_root}; -use crate::cli::{CacheSubcommand, Command, RunCommand}; +use crate::cli::{CacheSubcommand, Command, RunCommand, RunFlags}; #[derive(Debug)] enum LazyTaskGraph<'a> { @@ -98,13 +99,18 @@ impl vite_task_plan::PlanRequestParser for PlanRequestParser<'_> { match self.command_handler.handle_command(command).await? { HandledCommand::Synthesized(synthetic) => Ok(Some(PlanRequest::Synthetic(synthetic))), HandledCommand::ViteTaskCommand(cli_command) => match cli_command { - Command::Cache { .. } => Ok(Some(PlanRequest::Synthetic(SyntheticPlanRequest { - program: Arc::from(OsStr::new(command.program.as_str())), - args: Arc::clone(&command.args), - cache_config: UserCacheConfig::disabled(), - envs: Arc::clone(&command.envs), - }))), - Command::Run(run_command) => Ok(Some(run_command.into_plan_request(&command.cwd)?)), + Command::Cache { .. } => Ok(Some(PlanRequest::Synthetic( + command.to_synthetic_plan_request(UserCacheConfig::disabled()), + ))), + Command::Run(run_command) => match run_command.into_plan_request(&command.cwd) { + Ok(plan_request) => Ok(Some(plan_request)), + Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => { + Ok(Some(PlanRequest::Synthetic( + command.to_synthetic_plan_request(UserCacheConfig::disabled()), + ))) + } + Err(err) => Err(err.into()), + }, }, HandledCommand::Verbatim => Ok(None), } @@ -223,13 +229,52 @@ impl<'a> Session<'a> { Command::Cache { ref subcmd } => self.handle_cache_command(subcmd), Command::Run(run_command) => { let cwd = Arc::clone(&self.cwd); - let plan = self.plan_from_cli(cwd, run_command).await?; - let reporter = LabeledReporter::new(std::io::stdout(), self.workspace_path()); - Ok(self - .execute(plan, Box::new(reporter)) - .await - .err() - .unwrap_or(ExitStatus::SUCCESS)) + let is_interactive = + std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + + // Save task name and flags before consuming run_command + let task_name = run_command.task_specifier.as_ref().map(|s| s.task_name.clone()); + let flags = run_command.flags; + let additional_args = run_command.additional_args.clone(); + + match self.plan_from_cli(cwd, run_command).await { + Ok(plan) if plan.is_empty() => { + // No tasks matched the query — show task selector / "did you mean" + self.handle_no_task( + is_interactive, + task_name.as_deref(), + flags, + additional_args, + ) + .await + } + Ok(plan) => { + let reporter = + LabeledReporter::new(std::io::stdout(), self.workspace_path()); + Ok(self + .execute(plan, Box::new(reporter)) + .await + .err() + .unwrap_or(ExitStatus::SUCCESS)) + } + Err(err) if err.is_missing_task_specifier() => { + self.handle_no_task(is_interactive, None, flags, additional_args).await + } + Err(err) => { + if let Some(task_name) = err.task_not_found_name() { + let task_name = task_name.to_owned(); + self.handle_no_task( + is_interactive, + Some(&task_name), + flags, + additional_args, + ) + .await + } else { + Err(err.into()) + } + } + } } } } @@ -245,6 +290,119 @@ impl<'a> Session<'a> { } } + /// Handle the case where no task was specified or a task name was not found. + /// + /// In interactive mode, shows a fuzzy-searchable selection list. + /// In non-interactive mode, prints the task list or "did you mean" suggestions. + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + #[expect( + clippy::large_futures, + reason = "interactive select future is large but only awaited once" + )] + async fn handle_no_task( + &mut self, + is_interactive: bool, + not_found_name: Option<&str>, + flags: RunFlags, + additional_args: Vec, + ) -> anyhow::Result { + let cwd = Arc::clone(&self.cwd); + let task_graph = self.ensure_task_graph_loaded().await?; + let current_package_path = task_graph.get_package_path_from_cwd(&cwd).cloned(); + let mut entries = task_graph.list_tasks(); + entries.sort_unstable_by(|a, b| { + a.task_display + .package_name + .cmp(&b.task_display.package_name) + .then_with(|| a.task_display.task_name.cmp(&b.task_display.task_name)) + }); + + // Build items: current package tasks use unqualified names (no '#'), + // other packages use qualified "package#task" names. + let select_items: Vec = entries + .iter() + .map(|entry| { + let label = + if current_package_path.as_ref() == Some(&entry.task_display.package_path) { + entry.task_display.task_name.clone() + } else { + vite_str::format!("{}", entry.task_display) + }; + SelectItem { label, description: entry.command.clone() } + }) + .collect(); + + // Build header: interactive says "not found.", non-interactive "did you mean:" suffix + let header = not_found_name.map(|name| { + if is_interactive { + vite_str::format!("Task \"{name}\" not found.") + } else { + vite_str::format!("Task \"{name}\" not found. Did you mean:") + } + }); + + // Build mode-dependent params and call select_list once + let mut selected_index = if is_interactive { Some(0) } else { None }; + let mut stdout = std::io::stdout(); + let mode = + selected_index.as_mut().map_or(vite_select::Mode::NonInteractive, |selected_index| { + vite_select::Mode::Interactive { selected_index } + }); + + let params = vite_select::SelectParams { + items: &select_items, + query: not_found_name, + header: header.as_deref(), + page_size: 12, + }; + + vite_select::select_list( + &mut stdout, + ¶ms, + mode, + |filtered, query| { + // When the query doesn't contain '#', move current-package tasks (those + // without '#' in their label) to the top. `sort_by_key` is a stable sort, + // so the fuzzy rating order is preserved within each group. + if !query.contains('#') { + filtered.sort_by_key(|&idx| select_items[idx].label.contains('#')); + } + }, + |state| { + use std::io::Write; + let milestone_name = + vite_str::format!("task-select:{}:{}", state.query, state.selected_index); + let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name); + let mut out = std::io::stdout(); + let _ = out.write_all(&milestone_bytes); + let _ = out.flush(); + }, + )?; + + let Some(selected_index) = selected_index else { + // Non-interactive: if no task was found, return failure. Otherwise, print the list and return + return if not_found_name.is_some() { + Ok(ExitStatus::FAILURE) + } else { + Ok(ExitStatus::SUCCESS) + }; + }; + + // Interactive: run the selected task + let selected_label = &select_items[selected_index].label; + let task_specifier = TaskSpecifier::parse_raw(selected_label); + let run_command = + RunCommand { task_specifier: Some(task_specifier), flags, additional_args }; + + let cwd = Arc::clone(&self.cwd); + let plan = self.plan_from_cli(cwd, run_command).await?; + let reporter = LabeledReporter::new(std::io::stdout(), self.workspace_path()); + Ok(self.execute(plan, Box::new(reporter)).await.err().unwrap_or(ExitStatus::SUCCESS)) + } + /// Lazily initializes and returns the execution cache. /// The cache is only created when first accessed to avoid `SQLite` race conditions /// when multiple processes start simultaneously. @@ -323,15 +481,21 @@ impl<'a> Session<'a> { cwd: Arc, command: RunCommand, ) -> Result { - let plan_request = command.into_plan_request(&cwd).map_err(|error| { - TaskPlanErrorKind::ParsePlanRequestError { - error: error.into(), - program: Str::from("vp"), - args: Arc::default(), - cwd: Arc::clone(&cwd), + let plan_request = match command.into_plan_request(&cwd) { + Ok(plan_request) => plan_request, + Err(crate::cli::CLITaskQueryError::MissingTaskSpecifier) => { + return Err(TaskPlanErrorKind::MissingTaskSpecifier.with_empty_call_stack()); + } + Err(error) => { + return Err(TaskPlanErrorKind::ParsePlanRequestError { + error: error.into(), + program: Str::from("vp"), + args: Arc::default(), + cwd: Arc::clone(&cwd), + } + .with_empty_call_stack()); } - .with_empty_call_stack() - })?; + }; let plan = ExecutionPlan::plan( plan_request, &self.workspace_path, diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 6163a3c4..f98cc7cc 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -14,9 +14,7 @@ path = "src/main.rs" anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } -crossterm = { workspace = true } jsonc-parser = { workspace = true } -pty_terminal_test_client = { workspace = true } rustc-hash = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 20a7ed91..392a6df4 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -84,7 +84,6 @@ pub enum Args { name: Str, value: Str, }, - Interact, #[command(flatten)] Task(Command), } @@ -131,7 +130,6 @@ impl vite_task::CommandHandler for CommandHandler { envs: Arc::new(envs), })) } - Args::Interact => Ok(HandledCommand::Verbatim), Args::Task(cli_command) => Ok(HandledCommand::ViteTaskCommand(cli_command)), } } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index 9cd4f95f..4b9a5250 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -1,8 +1,4 @@ -use std::{ - io::{IsTerminal, Read, Write}, - process::ExitCode, - sync::Arc, -}; +use std::{process::ExitCode, sync::Arc}; use clap::Parser; use vite_str::Str; @@ -25,7 +21,6 @@ async fn run() -> anyhow::Result { let mut owned_callbacks = OwnedSessionCallbacks::default(); let session = Session::init(owned_callbacks.as_callbacks())?; match args { - Args::Interact => run_interact(), Args::Task(command) => { #[expect(clippy::large_futures, reason = "session.main produces a large future")] { @@ -67,155 +62,3 @@ async fn run() -> anyhow::Result { } } } - -fn write_line(stdout: &mut impl Write, line: &[u8]) -> anyhow::Result<()> { - stdout.write_all(line)?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - Ok(()) -} - -fn write_milestone(stdout: &mut impl Write, name: &str) -> anyhow::Result<()> { - stdout.write_all(&pty_terminal_test_client::encoded_milestone(name))?; - stdout.flush()?; - Ok(()) -} - -struct RawModeGuard { - enabled: bool, -} - -impl RawModeGuard { - fn new(enabled: bool) -> anyhow::Result { - if enabled { - crossterm::terminal::enable_raw_mode()?; - } - Ok(Self { enabled }) - } - - fn disable(&mut self) -> anyhow::Result<()> { - if self.enabled { - crossterm::terminal::disable_raw_mode()?; - self.enabled = false; - } - Ok(()) - } -} - -impl Drop for RawModeGuard { - fn drop(&mut self) { - if self.enabled { - let _ = crossterm::terminal::disable_raw_mode(); - } - } -} - -fn run_interact() -> anyhow::Result { - let stdin_is_tty = std::io::stdin().is_terminal(); - let enable_raw_mode = if cfg!(windows) { true } else { stdin_is_tty }; - let mut raw_mode = RawModeGuard::new(enable_raw_mode)?; - - let mut stdin = std::io::stdin(); - let mut stdout = std::io::stdout(); - let mut text_buffer = Vec::::new(); - let mut ansi_escape_pending = false; - let mut ansi_csi_pending = false; - let mut windows_extended_key_pending = false; - - write_line(&mut stdout, b"START")?; - write_milestone(&mut stdout, "ready")?; - - loop { - let mut byte = [0u8; 1]; - let read_count = stdin.read(&mut byte)?; - if read_count == 0 { - break; - } - - let byte = byte[0]; - if ansi_escape_pending { - ansi_escape_pending = false; - - if byte == b'[' || byte == b'O' { - ansi_csi_pending = true; - continue; - } - } - - if ansi_csi_pending { - ansi_csi_pending = false; - - if byte == b'A' { - write_milestone(&mut stdout, "after-up")?; - continue; - } - - if byte == b'B' { - write_milestone(&mut stdout, "after-down")?; - continue; - } - } - - if windows_extended_key_pending { - windows_extended_key_pending = false; - - if byte == 72 { - write_milestone(&mut stdout, "after-up")?; - continue; - } - - if byte == 80 { - write_milestone(&mut stdout, "after-down")?; - continue; - } - } - - if byte == 0x1b { - ansi_escape_pending = true; - continue; - } - - if byte == 0x00 || byte == 0xe0 { - windows_extended_key_pending = true; - continue; - } - - if byte == b'\r' { - if text_buffer.is_empty() { - write_line(&mut stdout, b"KEY:ENTER")?; - raw_mode.disable()?; - write_line(&mut stdout, b"DONE")?; - write_milestone(&mut stdout, "after-enter")?; - return Ok(ExitStatus::SUCCESS); - } - - stdout.write_all(b"LINE:")?; - stdout.write_all(&text_buffer)?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - text_buffer.clear(); - write_milestone(&mut stdout, "after-line")?; - continue; - } - - if byte == b'\n' { - if !text_buffer.is_empty() { - stdout.write_all(b"LINE:")?; - stdout.write_all(&text_buffer)?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - text_buffer.clear(); - write_milestone(&mut stdout, "after-line")?; - } - continue; - } - - text_buffer.push(byte); - stdout.write_all(b"CHAR:")?; - stdout.write_all(&[byte])?; - stdout.write_all(b"\r\n")?; - stdout.flush()?; - } - - Ok(ExitStatus::SUCCESS) -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json deleted file mode 100644 index cdef0840..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "interactions-no-vp", - "private": true -} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml deleted file mode 100644 index 22fd775a..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots.toml +++ /dev/null @@ -1,6 +0,0 @@ -[[e2e]] -name = "interactions without vp" -steps = [ - { command = "vp interact", interactions = [{ "expect-milestone" = "ready" }, { "write" = "hello" }, { "write-line" = "hello" }, { "expect-milestone" = "after-line" }, { "write-key" = "up" }, { "write-key" = "down" }, { "write" = "x" }, { "write-key" = "enter" }, { "expect-milestone" = "after-line" }, { "write-key" = "enter" }, { "expect-milestone" = "after-enter" }] }, - "echo -n | node -e \"console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))\"", -] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap deleted file mode 100644 index 91a74e97..00000000 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/interactions-no-vp/snapshots/interactions without vp.snap +++ /dev/null @@ -1,77 +0,0 @@ ---- -source: crates/vite_task_bin/tests/e2e_snapshots/main.rs -expression: e2e_outputs ---- -> vp interact -@ expect-milestone: ready -START -@ write: hello -@ write-line: hello -@ expect-milestone: after-line -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -@ write-key: up -@ write-key: down -@ write: x -@ write-key: enter -@ expect-milestone: after-line -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -CHAR:x -LINE:x -@ write-key: enter -@ expect-milestone: after-enter -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -CHAR:x -LINE:x -KEY:ENTER -DONE -START -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -CHAR:h -CHAR:e -CHAR:l -CHAR:l -CHAR:o -LINE:hellohello -CHAR:x -LINE:x -KEY:ENTER -DONE -> echo -n | node -e "console.log('PIPE_STDIN_IS_TTY:' + String(Boolean(process.stdin.isTTY)))" -PIPE_STDIN_IS_TTY:false diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json new file mode 100644 index 00000000..8858e597 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/package.json @@ -0,0 +1,4 @@ +{ + "name": "task-list-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json new file mode 100644 index 00000000..45aeb51c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "app", + "scripts": { + "build": "echo build app", + "test": "echo test app" + }, + "dependencies": { + "lib": "workspace:*" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json new file mode 100644 index 00000000..2814f38e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/app/vite-task.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "build": {}, + "test": {}, + "lint": { + "command": "echo lint app" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json new file mode 100644 index 00000000..5a2cc4d3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "lib", + "scripts": { + "build": "echo build lib" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json new file mode 100644 index 00000000..355525a7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/packages/lib/vite-task.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "build": {} + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml new file mode 100644 index 00000000..446672c4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots.toml @@ -0,0 +1,18 @@ +[[e2e]] +name = "list tasks from package dir" +cwd = "packages/app" +steps = [ + "echo '' | vp run", +] + +[[e2e]] +name = "list tasks from workspace root" +steps = [ + "echo '' | vp run", +] + +[[e2e]] +name = "vp run in script" +steps = [ + "vp run list-tasks", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap new file mode 100644 index 00000000..40607a14 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from package dir.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo '' | vp run + build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + task-list-test#hello: echo hello from root + task-list-test#list-tasks: vp run diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap new file mode 100644 index 00000000..000a9748 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/list tasks from workspace root.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo '' | vp run + hello: echo hello from root + list-tasks: vp run + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + lib#build: echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap new file mode 100644 index 00000000..f4210397 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap @@ -0,0 +1,26 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run list-tasks +$ vp run ⊘ cache disabled: no cache config + hello: echo hello from root + list-tasks: vp run + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + lib#build: echo build lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] task-list-test#list-tasks: $ vp run ✓ + → Cache disabled in task configuration +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json new file mode 100644 index 00000000..1e2ed69c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/vite-task.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "hello": { + "command": "echo hello from root" + }, + "list-tasks": { + "command": "vp run" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json new file mode 100644 index 00000000..33d52e30 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/package.json @@ -0,0 +1,4 @@ +{ + "name": "task-select-truncate-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json new file mode 100644 index 00000000..0a2d4152 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json new file mode 100644 index 00000000..4eddc58c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/packages/app/vite-task.json @@ -0,0 +1,16 @@ +{ + "tasks": { + "build": { + "command": "echo build app" + }, + "lint": { + "command": "echo lint app" + }, + "long-cmd": { + "command": "echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "test": { + "command": "echo test app" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml new file mode 100644 index 00000000..751f2c1f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots.toml @@ -0,0 +1,7 @@ +# Interactive: long commands are truncated to terminal width (no line wrapping) +[[e2e]] +name = "interactive long command truncated" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::1" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::2" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::3" }, { "write-key" = "up" }, { "expect-milestone" = "task-select::2" }, { "write-key" = "enter" }] }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap new file mode 100644 index 00000000..c63b3ce7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap @@ -0,0 +1,56 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: down +@ expect-milestone: task-select::1 +Search task (↑/↓ to move, enter to select): + build: echo build app +> lint: echo lint app + long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: down +@ expect-milestone: task-select::2 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app +> long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: down +@ expect-milestone: task-select::3 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app + long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… +> test: echo test app +@ write-key: up +@ expect-milestone: task-select::2 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app +> long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… + test: echo test app +@ write-key: enter +~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ⊘ cache disabled: built-in command +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#long-cmd: ~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json new file mode 100644 index 00000000..90faa728 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/vite-task.json @@ -0,0 +1,3 @@ +{ + "tasks": {} +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json new file mode 100644 index 00000000..b3ea6388 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/package.json @@ -0,0 +1,4 @@ +{ + "name": "task-select-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json new file mode 100644 index 00000000..7b19151f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "app", + "private": true, + "dependencies": { + "lib": "workspace:*" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json new file mode 100644 index 00000000..9f842f77 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/app/vite-task.json @@ -0,0 +1,13 @@ +{ + "tasks": { + "build": { + "command": "echo build app" + }, + "lint": { + "command": "echo lint app" + }, + "test": { + "command": "echo test app" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json new file mode 100644 index 00000000..42510612 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "lib", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json new file mode 100644 index 00000000..93d1597c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/packages/lib/vite-task.json @@ -0,0 +1,16 @@ +{ + "tasks": { + "build": { + "command": "echo build lib" + }, + "lint": { + "command": "echo lint lib" + }, + "test": { + "command": "echo test lib" + }, + "typecheck": { + "command": "echo typecheck lib" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml new file mode 100644 index 00000000..fc9c0672 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml @@ -0,0 +1,125 @@ +# Non-interactive: list all tasks (piped stdin forces non-interactive mode) +[[e2e]] +name = "non-interactive list tasks" +steps = [ + "echo '' | vp run", +] + +# Non-interactive: typo triggers "did you mean" +[[e2e]] +name = "non-interactive did you mean" +steps = [ + "echo '' | vp run buid", +] + +# Non-interactive: typo with -r flag +[[e2e]] +name = "non-interactive did you mean with recursive" +steps = [ + "echo '' | vp run -r buid", +] + +# Interactive: navigate down and select second task +[[e2e]] +name = "interactive select task" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::1" }, { "write-key" = "enter" }] }, +] + +# Interactive: typo pre-filled in search +[[e2e]] +name = "interactive select with typo" +cwd = "packages/app" +steps = [ + { command = "vp run buid", interactions = [{ "expect-milestone" = "task-select:buid:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: type to search then select +[[e2e]] +name = "interactive search then select" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lin" }, { "expect-milestone" = "task-select:lin:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: escape clears query and resets filter +[[e2e]] +name = "interactive escape clears query" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lin" }, { "expect-milestone" = "task-select:lin:0" }, { "write-key" = "escape" }, { "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, +] + +# Interactive: -r flag preserved +[[e2e]] +name = "interactive select with recursive" +cwd = "packages/app" +steps = [ + { command = "vp run -r", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, +] + +# Interactive: -t flag + typo +[[e2e]] +name = "interactive select with typo and transitive" +cwd = "packages/app" +steps = [ + { command = "vp run -t buid", interactions = [{ "expect-milestone" = "task-select:buid:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: scroll down past visible page, then select a task beyond the initial viewport +[[e2e]] +name = "interactive scroll long list" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, # Navigate down to index 8 (past page_size=8, triggering scroll) + { "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "write-key" = "down" },{ "expect-milestone" = "task-select::8" }, # Scroll back up to the top + { "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "write-key" = "up" },{ "expect-milestone" = "task-select::0" },{ "write-key" = "enter" },] }, +] + +# Non-interactive: list tasks from lib package (lib tasks first, unqualified) +[[e2e]] +name = "non-interactive list tasks from lib" +cwd = "packages/lib" +steps = [ + "echo '' | vp run", +] + +# Interactive: select from lib package (first item is lib's task) +[[e2e]] +name = "interactive select task from lib" +cwd = "packages/lib" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, +] + +# Interactive: search for a task that only exists in another package +[[e2e]] +name = "interactive search other package task" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "typec" }, { "expect-milestone" = "task-select:typec:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: '#' in query skips current-package reordering +[[e2e]] +name = "interactive search with hash skips reorder" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "lib#" }, { "expect-milestone" = "task-select:lib#:0" }, { "write-key" = "enter" }] }, +] + +# Interactive: multiple current-package matches preserve fuzzy rating order +[[e2e]] +name = "interactive search preserves rating within package" +cwd = "packages/lib" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "t" }, { "expect-milestone" = "task-select:t:0" }, { "write-key" = "enter" }] }, +] + +# Typo inside a task script should fail with an error, NOT show a list +[[e2e]] +name = "typo in task script fails without list" +steps = [ + "vp run run-typo-task", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap new file mode 100644 index 00000000..6d50d728 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap @@ -0,0 +1,58 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write: lin +@ expect-milestone: task-select:lin:0 +Search task (↑/↓ to move, enter to select): lin +> lint: echo lint app + lib#lint: echo lint lib +@ write-key: escape +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: enter +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap new file mode 100644 index 00000000..fbd4a69a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap @@ -0,0 +1,83 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ write-key: down +@ expect-milestone: task-select::8 +Search task (↑/↓ to move, enter to select): + build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root +> task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ write-key: up +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: enter +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap new file mode 100644 index 00000000..50258c2d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap @@ -0,0 +1,41 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write: typec +@ expect-milestone: task-select:typec:0 +Search task (↑/↓ to move, enter to select): typec +> lib#typecheck: echo typecheck lib +@ write-key: enter +~/packages/lib$ echo typecheck lib ⊘ cache disabled: built-in command +typecheck lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#typecheck: ~/packages/lib$ echo typecheck lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap new file mode 100644 index 00000000..b844dac0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap @@ -0,0 +1,53 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write: t +@ expect-milestone: task-select:t:0 +Search task (↑/↓ to move, enter to select): t +> test: echo test lib + typecheck: echo typecheck lib + lint: echo lint lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + task-select-test#hello: echo hello from root + task-select-test#run-typo-task: vp run nonexistent-xyz + task-select-test#validate: echo validate root + app#test: echo test app + (…1 more) +@ write-key: enter +~/packages/lib$ echo test lib ⊘ cache disabled: built-in command +test lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#test: ~/packages/lib$ echo test lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap new file mode 100644 index 00000000..6e3ac272 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap @@ -0,0 +1,42 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write: lin +@ expect-milestone: task-select:lin:0 +Search task (↑/↓ to move, enter to select): lin +> lint: echo lint app + lib#lint: echo lint lib +@ write-key: enter +~/packages/app$ echo lint app ⊘ cache disabled: built-in command +lint app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#lint: ~/packages/app$ echo lint app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap new file mode 100644 index 00000000..881383e8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap @@ -0,0 +1,44 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write: lib# +@ expect-milestone: task-select:lib#:0 +Search task (↑/↓ to move, enter to select): lib# +> lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap new file mode 100644 index 00000000..b95f84e9 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap @@ -0,0 +1,37 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap new file mode 100644 index 00000000..0d770bd0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap @@ -0,0 +1,53 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: down +@ expect-milestone: task-select::1 +Search task (↑/↓ to move, enter to select): + build: echo build app +> lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: enter +~/packages/app$ echo lint app ⊘ cache disabled: built-in command +lint app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#lint: ~/packages/app$ echo lint app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap new file mode 100644 index 00000000..e9f81aef --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with recursive.snap @@ -0,0 +1,43 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run -r +@ expect-milestone: task-select::0 +Search task (↑/↓ to move, enter to select): +> build: echo build app + lint: echo lint app + test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + (…3 more) +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 2 tasks • 0 cache hits • 0 cache misses • 2 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command + ······················································· + [2] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap new file mode 100644 index 00000000..75d72250 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo and transitive.snap @@ -0,0 +1,33 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run -t buid +@ expect-milestone: task-select:buid:0 +Task "buid" not found. +Search task (↑/↓ to move, enter to select): buid +> build: echo build app + lib#build: echo build lib +@ write-key: enter +~/packages/lib$ echo build lib ⊘ cache disabled: built-in command +build lib + +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 2 tasks • 0 cache hits • 0 cache misses • 2 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] lib#build: ~/packages/lib$ echo build lib ✓ + → Cache disabled for built-in command + ······················································· + [2] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap new file mode 100644 index 00000000..6d8e0d76 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap @@ -0,0 +1,27 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run buid +@ expect-milestone: task-select:buid:0 +Task "buid" not found. +Search task (↑/↓ to move, enter to select): buid +> build: echo build app + lib#build: echo build lib +@ write-key: enter +~/packages/app$ echo build app ⊘ cache disabled: built-in command +build app + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 0 cache misses • 1 cache disabled +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] app#build: ~/packages/app$ echo build app ✓ + → Cache disabled for built-in command +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap new file mode 100644 index 00000000..f8940b84 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean with recursive.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> echo '' | vp run -r buid +Task "buid" not found. Did you mean: + app#build: echo build app + lib#build: echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap new file mode 100644 index 00000000..e6ecb83d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive did you mean.snap @@ -0,0 +1,8 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> echo '' | vp run buid +Task "buid" not found. Did you mean: + app#build: echo build app + lib#build: echo build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap new file mode 100644 index 00000000..da25a4b6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks from lib.snap @@ -0,0 +1,20 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo '' | vp run + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + task-select-test#check: echo check root + task-select-test#clean: echo clean root + task-select-test#deploy: echo deploy root + task-select-test#docs: echo docs root + task-select-test#format: echo format root + task-select-test#hello: echo hello from root + task-select-test#run-typo-task: vp run nonexistent-xyz + task-select-test#validate: echo validate root diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap new file mode 100644 index 00000000..7d6e097d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/non-interactive list tasks.snap @@ -0,0 +1,20 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> echo '' | vp run + check: echo check root + clean: echo clean root + deploy: echo deploy root + docs: echo docs root + format: echo format root + hello: echo hello from root + run-typo-task: vp run nonexistent-xyz + validate: echo validate root + app#build: echo build app + app#lint: echo lint app + app#test: echo test app + lib#build: echo build lib + lib#lint: echo lint lib + lib#test: echo test lib + lib#typecheck: echo typecheck lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap new file mode 100644 index 00000000..cb58fd0d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/typo in task script fails without list.snap @@ -0,0 +1,11 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> vp run run-typo-task +Error: Failed to plan execution, task call stack: task-select-test#run-typo-task + +Caused by: + 0: Failed to query tasks from task graph + 1: Failed to look up task from specifier: nonexistent-xyz + 2: Task 'nonexistent-xyz' not found in package task-select-test diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json new file mode 100644 index 00000000..4ef32234 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/vite-task.json @@ -0,0 +1,28 @@ +{ + "tasks": { + "check": { + "command": "echo check root" + }, + "clean": { + "command": "echo clean root" + }, + "deploy": { + "command": "echo deploy root" + }, + "docs": { + "command": "echo docs root" + }, + "format": { + "command": "echo format root" + }, + "hello": { + "command": "echo hello from root" + }, + "run-typo-task": { + "command": "vp run nonexistent-xyz" + }, + "validate": { + "command": "echo validate root" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 3581c239..07e65543 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -161,6 +161,8 @@ enum WriteKey { Up, Down, Enter, + Escape, + Backspace, } impl WriteKey { @@ -169,6 +171,8 @@ impl WriteKey { Self::Up => "up", Self::Down => "down", Self::Enter => "enter", + Self::Escape => "escape", + Self::Backspace => "backspace", } } @@ -177,6 +181,8 @@ impl WriteKey { Self::Up => b"\x1b[A", Self::Down => b"\x1b[B", Self::Enter => b"\r", + Self::Escape => b"\x1b", + Self::Backspace => b"\x7f", } } } diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs index 3e60b5a1..fbc06dd2 100644 --- a/crates/vite_task_graph/src/display.rs +++ b/crates/vite_task_graph/src/display.rs @@ -27,10 +27,32 @@ impl Display for TaskDisplay { } } +/// A task with its display info and command, for listing purposes. +#[derive(Debug)] +pub struct TaskListEntry { + pub task_display: TaskDisplay, + pub command: Str, +} + impl IndexedTaskGraph { /// Get human-readable display for a task node. #[must_use] pub fn display_task(&self, task_index: TaskNodeIndex) -> TaskDisplay { self.task_graph()[task_index].task_display.clone() } + + /// Returns all tasks as a flat list. + #[must_use] + pub fn list_tasks(&self) -> Vec { + self.task_graph() + .node_indices() + .map(|idx| { + let node = &self.task_graph()[idx]; + TaskListEntry { + task_display: node.task_display.clone(), + command: node.resolved_config.command.clone(), + } + }) + .collect() + } } diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index f578b73a..1c8698fe 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -506,4 +506,12 @@ impl IndexedTaskGraph { pub fn get_package_path_for_task(&self, task_index: TaskNodeIndex) -> &Arc { &self.task_graph[task_index].task_display.package_path } + + /// Get the package path for a given current working directory by traversing up the directory + /// tree to find the nearest package. + #[must_use] + pub fn get_package_path_from_cwd(&self, cwd: &AbsolutePath) -> Option<&Arc> { + let index = self.indexed_package_graph.get_package_index_from_cwd(cwd)?; + Some(self.get_package_path(index)) + } } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index 35212f8e..e29faceb 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -134,6 +134,9 @@ pub enum TaskPlanErrorKind { #[error("Failed to resolve environment variables")] ResolveEnvError(#[source] ResolveEnvError), + + #[error("No task specifier provided for 'run' command")] + MissingTaskSpecifier, } #[derive(Debug, thiserror::Error)] @@ -161,6 +164,32 @@ impl TaskPlanErrorKind { } } +impl Error { + #[must_use] + pub const fn is_missing_task_specifier(&self) -> bool { + matches!(self.kind, TaskPlanErrorKind::MissingTaskSpecifier) + } + + /// If this error represents a top-level task-not-found lookup failure, + /// returns the task name that the user typed. + /// + /// Returns `None` if the error occurred in a nested task (non-empty call stack), + /// since nested task errors should propagate as-is rather than triggering + /// interactive task selection. + #[must_use] + pub fn task_not_found_name(&self) -> Option<&str> { + if !self.task_call_stack.is_empty() { + return None; + } + match &self.kind { + TaskPlanErrorKind::TaskQueryError( + vite_task_graph::query::TaskQueryError::SpecifierLookupError { specifier, .. }, + ) => Some(specifier.task_name.as_str()), + _ => None, + } + } +} + #[expect( clippy::result_large_err, reason = "Error wraps TaskPlanErrorKind with call stack for diagnostics" diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 8a383f7d..b1221cc7 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -192,6 +192,15 @@ impl ExecutionPlan { &self.root_node } + /// Returns `true` if the plan contains no tasks to execute. + #[must_use] + pub fn is_empty(&self) -> bool { + match &self.root_node { + ExecutionItemKind::Expanded(graph) => graph.node_count() == 0, + ExecutionItemKind::Leaf(_) => false, + } + } + /// Plan an execution from a plan request. /// /// # Errors