From 6bc6f22b0750244f88a2e60dc6cb84bf45ee5290 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 14 Jan 2026 03:08:58 +0800 Subject: [PATCH 1/2] feat(e2e): add 10s timeout for test steps Add timeout support for e2e snapshot tests to prevent hanging tests: - Convert test harness to async using tokio runtime - Each step has a 10-second timeout - When timeout occurs: - Kill the child process - Mark step with [timeout] instead of exit code - Capture partial stdout/stderr before timeout - Skip remaining steps in the test case - Uses concurrent I/O to read stdout/stderr while waiting Co-Authored-By: Claude Opus 4.5 --- .../vite_task_bin/tests/e2e_snapshots/main.rs | 132 ++++++++++++++---- 1 file changed, 107 insertions(+), 25 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 2acba245..722cb7ee 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,98 @@ 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 exit_code = output.status.code().unwrap_or(-1); - if exit_code != 0 { - e2e_outputs.push_str(format!("[{}]", exit_code).as_str()); + 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; + let mut timed_out = false; + let mut exit_status: Option = None; + + let timeout = tokio::time::sleep(STEP_TIMEOUT); + tokio::pin!(timeout); + + 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 exit_status.is_none() => { + exit_status = Some(result.unwrap()); + } + _ = &mut timeout, if !timed_out => { + // Timeout - kill the process + let _ = child.kill().await; + timed_out = true; + } + } + + // Exit conditions: + // 1. Process exited and all output drained + // 2. Timed out and all output drained (after kill, pipes close) + if (exit_status.is_some() || timed_out) && stdout_done && stderr_done { + break; + } + } + + // Format output + if timed_out { + e2e_outputs.push_str("[timeout]"); + } else if let Some(status) = exit_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 timed_out { + break; + } } insta::assert_snapshot!(e2e.name.as_str(), e2e_outputs); } @@ -212,9 +293,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()) + }); } From c5690c9e00b1cf0fe06d944d6493e9a46d26833f Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 14 Jan 2026 22:51:51 +0800 Subject: [PATCH 2/2] refactor(e2e): use TerminationState enum for process state Replace separate `timed_out` and `exit_status` variables with a unified `TerminationState` enum that better represents the three possible states: - Running (implicitly `None`) - Exited with status - Timed out Benefits: - Eliminates impossible state combinations - More idiomatic Rust with pattern matching - Clearer intent and better type safety - Loop now returns the termination state directly Co-Authored-By: Claude Sonnet 4.5 --- .../vite_task_bin/tests/e2e_snapshots/main.rs | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 722cb7ee..8913304c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -213,13 +213,18 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name // Read chunks concurrently with process wait, using select! with timeout let mut stdout_done = false; let mut stderr_done = false; - let mut timed_out = false; - let mut exit_status: Option = None; + + 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); - loop { + let termination_state = loop { let mut stdout_chunk = [0u8; 8192]; let mut stderr_chunk = [0u8; 8192]; @@ -238,31 +243,37 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name Err(_) => stderr_done = true, } } - result = child.wait(), if exit_status.is_none() => { - exit_status = Some(result.unwrap()); + result = child.wait(), if termination_state.is_none() => { + termination_state = Some(TerminationState::Exited(result.unwrap())); } - _ = &mut timeout, if !timed_out => { + _ = &mut timeout, if termination_state.is_none() => { // Timeout - kill the process let _ = child.kill().await; - timed_out = true; + 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 (exit_status.is_some() || timed_out) && stdout_done && stderr_done { - break; + if let Some(termination_state) = &termination_state + && stdout_done + && stderr_done + { + break termination_state; } - } + }; // Format output - if timed_out { - e2e_outputs.push_str("[timeout]"); - } else if let Some(status) = exit_status { - let exit_code = status.code().unwrap_or(-1); - if exit_code != 0 { - e2e_outputs.push_str(format!("[{}]", exit_code).as_str()); + 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()); + } } } @@ -277,7 +288,7 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name e2e_outputs.push('\n'); // Skip remaining steps if timed out - if timed_out { + if matches!(termination_state, TerminationState::TimedOut) { break; } }