diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 2acba245..8913304c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,18 +3,25 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, - io::Write, path::{Path, PathBuf}, - process::{Command, Stdio}, + process::Stdio, sync::Arc, + time::Duration, }; use copy_dir::copy_dir; use redact::redact_e2e_output; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + process::Command, +}; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vite_str::Str; use vite_workspace::find_workspace_root; +/// Timeout for each step in e2e tests +const STEP_TIMEOUT: Duration = Duration::from_secs(10); + /// Get the shell executable for running e2e test steps. /// On Unix, uses /bin/sh. /// On Windows, uses BASH env var or falls back to Git Bash. @@ -77,7 +84,12 @@ struct SnapshotsFile { pub e2e_cases: Vec, } -fn run_case(tmpdir: &AbsolutePath, fixture_path: &Path, filter: Option<&str>) { +fn run_case( + runtime: &tokio::runtime::Runtime, + tmpdir: &AbsolutePath, + fixture_path: &Path, + filter: Option<&str>, +) { let fixture_name = fixture_path.file_name().unwrap().to_str().unwrap(); if fixture_name.starts_with(".") { return; // skip hidden files like .DS_Store @@ -96,10 +108,11 @@ fn run_case(tmpdir: &AbsolutePath, fixture_path: &Path, filter: Option<&str>) { settings.set_prepend_module_to_snapshot(false); settings.remove_snapshot_suffix(); - settings.bind(|| run_case_inner(tmpdir, fixture_path, fixture_name)); + // Use block_on inside bind to run async code with insta settings applied + settings.bind(|| runtime.block_on(run_case_inner(tmpdir, fixture_path, fixture_name))); } -fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name: &str) { +async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name: &str) { // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case. let stage_path = tmpdir.join(fixture_name); copy_dir(fixture_path, &stage_path).unwrap(); @@ -175,30 +188,109 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name: &str } } - let output = if let Some(stdin_content) = step.stdin() { - cmd.stdin(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - let mut child = cmd.spawn().unwrap(); - child.stdin.take().unwrap().write_all(stdin_content.as_bytes()).unwrap(); - child.wait_with_output().unwrap() - } else { - cmd.output().unwrap() + // Spawn the child process + cmd.stdin(if step.stdin().is_some() { Stdio::piped() } else { Stdio::null() }); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn().unwrap(); + + // Write stdin if provided, then close it + if let Some(stdin_content) = step.stdin() { + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(stdin_content.as_bytes()).await.unwrap(); + drop(stdin); // Close stdin to signal EOF + } + + // Take stdout/stderr handles + let mut stdout_handle = child.stdout.take().unwrap(); + let mut stderr_handle = child.stderr.take().unwrap(); + + // Buffers for accumulating output + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + + // Read chunks concurrently with process wait, using select! with timeout + let mut stdout_done = false; + let mut stderr_done = false; + + enum TerminationState { + Exited(std::process::ExitStatus), + TimedOut, + } + // Initial state is running + let mut termination_state: Option = None; + + let timeout = tokio::time::sleep(STEP_TIMEOUT); + tokio::pin!(timeout); + + let termination_state = loop { + let mut stdout_chunk = [0u8; 8192]; + let mut stderr_chunk = [0u8; 8192]; + + tokio::select! { + result = stdout_handle.read(&mut stdout_chunk), if !stdout_done => { + match result { + Ok(0) => stdout_done = true, + Ok(n) => stdout_buf.extend_from_slice(&stdout_chunk[..n]), + Err(_) => stdout_done = true, + } + } + result = stderr_handle.read(&mut stderr_chunk), if !stderr_done => { + match result { + Ok(0) => stderr_done = true, + Ok(n) => stderr_buf.extend_from_slice(&stderr_chunk[..n]), + Err(_) => stderr_done = true, + } + } + result = child.wait(), if termination_state.is_none() => { + termination_state = Some(TerminationState::Exited(result.unwrap())); + } + _ = &mut timeout, if termination_state.is_none() => { + // Timeout - kill the process + let _ = child.kill().await; + termination_state = Some(TerminationState::TimedOut); + } + } + + // Exit conditions: + // 1. Process exited and all output drained + // 2. Timed out and all output drained (after kill, pipes close) + if let Some(termination_state) = &termination_state + && stdout_done + && stderr_done + { + break termination_state; + } }; - let exit_code = output.status.code().unwrap_or(-1); - if exit_code != 0 { - e2e_outputs.push_str(format!("[{}]", exit_code).as_str()); + // Format output + match termination_state { + TerminationState::TimedOut => { + e2e_outputs.push_str("[timeout]"); + } + TerminationState::Exited(status) => { + let exit_code = status.code().unwrap_or(-1); + if exit_code != 0 { + e2e_outputs.push_str(format!("[{}]", exit_code).as_str()); + } + } } + e2e_outputs.push_str("> "); e2e_outputs.push_str(step.cmd()); e2e_outputs.push('\n'); - let stdout = String::from_utf8(output.stdout).unwrap(); - let stderr = String::from_utf8(output.stderr).unwrap(); + let stdout = String::from_utf8_lossy(&stdout_buf).into_owned(); + let stderr = String::from_utf8_lossy(&stderr_buf).into_owned(); e2e_outputs.push_str(&redact_e2e_output(stdout, e2e_stage_path_str)); e2e_outputs.push_str(&redact_e2e_output(stderr, e2e_stage_path_str)); e2e_outputs.push('\n'); + + // Skip remaining steps if timed out + if matches!(termination_state, TerminationState::TimedOut) { + break; + } } insta::assert_snapshot!(e2e.name.as_str(), e2e_outputs); } @@ -212,9 +304,10 @@ fn main() { let tests_dir = std::env::current_dir().unwrap().join("tests"); - insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| run_case( - &tmp_dir_path, - case_path, - filter.as_deref() - )); + // Create tokio runtime for async operations + let runtime = tokio::runtime::Runtime::new().unwrap(); + + insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| { + run_case(&runtime, &tmp_dir_path, case_path, filter.as_deref()) + }); }