@@ -3,18 +3,25 @@ mod redact;
33use std:: {
44 env:: { self , join_paths, split_paths} ,
55 ffi:: OsStr ,
6- io:: Write ,
76 path:: { Path , PathBuf } ,
8- process:: { Command , Stdio } ,
7+ process:: Stdio ,
98 sync:: Arc ,
9+ time:: Duration ,
1010} ;
1111
1212use copy_dir:: copy_dir;
1313use redact:: redact_e2e_output;
14+ use tokio:: {
15+ io:: { AsyncReadExt , AsyncWriteExt } ,
16+ process:: Command ,
17+ } ;
1418use vite_path:: { AbsolutePath , AbsolutePathBuf , RelativePathBuf } ;
1519use vite_str:: Str ;
1620use vite_workspace:: find_workspace_root;
1721
22+ /// Timeout for each step in e2e tests
23+ const STEP_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
24+
1825/// Get the shell executable for running e2e test steps.
1926/// On Unix, uses /bin/sh.
2027/// On Windows, uses BASH env var or falls back to Git Bash.
@@ -77,7 +84,12 @@ struct SnapshotsFile {
7784 pub e2e_cases : Vec < E2e > ,
7885}
7986
80- fn run_case ( tmpdir : & AbsolutePath , fixture_path : & Path , filter : Option < & str > ) {
87+ fn run_case (
88+ runtime : & tokio:: runtime:: Runtime ,
89+ tmpdir : & AbsolutePath ,
90+ fixture_path : & Path ,
91+ filter : Option < & str > ,
92+ ) {
8193 let fixture_name = fixture_path. file_name ( ) . unwrap ( ) . to_str ( ) . unwrap ( ) ;
8294 if fixture_name. starts_with ( "." ) {
8395 return ; // skip hidden files like .DS_Store
@@ -96,10 +108,11 @@ fn run_case(tmpdir: &AbsolutePath, fixture_path: &Path, filter: Option<&str>) {
96108 settings. set_prepend_module_to_snapshot ( false ) ;
97109 settings. remove_snapshot_suffix ( ) ;
98110
99- settings. bind ( || run_case_inner ( tmpdir, fixture_path, fixture_name) ) ;
111+ // Use block_on inside bind to run async code with insta settings applied
112+ settings. bind ( || runtime. block_on ( run_case_inner ( tmpdir, fixture_path, fixture_name) ) ) ;
100113}
101114
102- fn run_case_inner ( tmpdir : & AbsolutePath , fixture_path : & Path , fixture_name : & str ) {
115+ async fn run_case_inner ( tmpdir : & AbsolutePath , fixture_path : & Path , fixture_name : & str ) {
103116 // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case.
104117 let stage_path = tmpdir. join ( fixture_name) ;
105118 copy_dir ( fixture_path, & stage_path) . unwrap ( ) ;
@@ -175,30 +188,109 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name: &str
175188 }
176189 }
177190
178- let output = if let Some ( stdin_content) = step. stdin ( ) {
179- cmd. stdin ( Stdio :: piped ( ) ) ;
180- cmd. stdout ( Stdio :: piped ( ) ) ;
181- cmd. stderr ( Stdio :: piped ( ) ) ;
182- let mut child = cmd. spawn ( ) . unwrap ( ) ;
183- child. stdin . take ( ) . unwrap ( ) . write_all ( stdin_content. as_bytes ( ) ) . unwrap ( ) ;
184- child. wait_with_output ( ) . unwrap ( )
185- } else {
186- cmd. output ( ) . unwrap ( )
191+ // Spawn the child process
192+ cmd. stdin ( if step. stdin ( ) . is_some ( ) { Stdio :: piped ( ) } else { Stdio :: null ( ) } ) ;
193+ cmd. stdout ( Stdio :: piped ( ) ) ;
194+ cmd. stderr ( Stdio :: piped ( ) ) ;
195+
196+ let mut child = cmd. spawn ( ) . unwrap ( ) ;
197+
198+ // Write stdin if provided, then close it
199+ if let Some ( stdin_content) = step. stdin ( ) {
200+ let mut stdin = child. stdin . take ( ) . unwrap ( ) ;
201+ stdin. write_all ( stdin_content. as_bytes ( ) ) . await . unwrap ( ) ;
202+ drop ( stdin) ; // Close stdin to signal EOF
203+ }
204+
205+ // Take stdout/stderr handles
206+ let mut stdout_handle = child. stdout . take ( ) . unwrap ( ) ;
207+ let mut stderr_handle = child. stderr . take ( ) . unwrap ( ) ;
208+
209+ // Buffers for accumulating output
210+ let mut stdout_buf = Vec :: new ( ) ;
211+ let mut stderr_buf = Vec :: new ( ) ;
212+
213+ // Read chunks concurrently with process wait, using select! with timeout
214+ let mut stdout_done = false ;
215+ let mut stderr_done = false ;
216+
217+ enum TerminationState {
218+ Exited ( std:: process:: ExitStatus ) ,
219+ TimedOut ,
220+ }
221+ // Initial state is running
222+ let mut termination_state: Option < TerminationState > = None ;
223+
224+ let timeout = tokio:: time:: sleep ( STEP_TIMEOUT ) ;
225+ tokio:: pin!( timeout) ;
226+
227+ let termination_state = loop {
228+ let mut stdout_chunk = [ 0u8 ; 8192 ] ;
229+ let mut stderr_chunk = [ 0u8 ; 8192 ] ;
230+
231+ tokio:: select! {
232+ result = stdout_handle. read( & mut stdout_chunk) , if !stdout_done => {
233+ match result {
234+ Ok ( 0 ) => stdout_done = true ,
235+ Ok ( n) => stdout_buf. extend_from_slice( & stdout_chunk[ ..n] ) ,
236+ Err ( _) => stdout_done = true ,
237+ }
238+ }
239+ result = stderr_handle. read( & mut stderr_chunk) , if !stderr_done => {
240+ match result {
241+ Ok ( 0 ) => stderr_done = true ,
242+ Ok ( n) => stderr_buf. extend_from_slice( & stderr_chunk[ ..n] ) ,
243+ Err ( _) => stderr_done = true ,
244+ }
245+ }
246+ result = child. wait( ) , if termination_state. is_none( ) => {
247+ termination_state = Some ( TerminationState :: Exited ( result. unwrap( ) ) ) ;
248+ }
249+ _ = & mut timeout, if termination_state. is_none( ) => {
250+ // Timeout - kill the process
251+ let _ = child. kill( ) . await ;
252+ termination_state = Some ( TerminationState :: TimedOut ) ;
253+ }
254+ }
255+
256+ // Exit conditions:
257+ // 1. Process exited and all output drained
258+ // 2. Timed out and all output drained (after kill, pipes close)
259+ if let Some ( termination_state) = & termination_state
260+ && stdout_done
261+ && stderr_done
262+ {
263+ break termination_state;
264+ }
187265 } ;
188266
189- let exit_code = output. status . code ( ) . unwrap_or ( -1 ) ;
190- if exit_code != 0 {
191- e2e_outputs. push_str ( format ! ( "[{}]" , exit_code) . as_str ( ) ) ;
267+ // Format output
268+ match termination_state {
269+ TerminationState :: TimedOut => {
270+ e2e_outputs. push_str ( "[timeout]" ) ;
271+ }
272+ TerminationState :: Exited ( status) => {
273+ let exit_code = status. code ( ) . unwrap_or ( -1 ) ;
274+ if exit_code != 0 {
275+ e2e_outputs. push_str ( format ! ( "[{}]" , exit_code) . as_str ( ) ) ;
276+ }
277+ }
192278 }
279+
193280 e2e_outputs. push_str ( "> " ) ;
194281 e2e_outputs. push_str ( step. cmd ( ) ) ;
195282 e2e_outputs. push ( '\n' ) ;
196283
197- let stdout = String :: from_utf8 ( output . stdout ) . unwrap ( ) ;
198- let stderr = String :: from_utf8 ( output . stderr ) . unwrap ( ) ;
284+ let stdout = String :: from_utf8_lossy ( & stdout_buf ) . into_owned ( ) ;
285+ let stderr = String :: from_utf8_lossy ( & stderr_buf ) . into_owned ( ) ;
199286 e2e_outputs. push_str ( & redact_e2e_output ( stdout, e2e_stage_path_str) ) ;
200287 e2e_outputs. push_str ( & redact_e2e_output ( stderr, e2e_stage_path_str) ) ;
201288 e2e_outputs. push ( '\n' ) ;
289+
290+ // Skip remaining steps if timed out
291+ if matches ! ( termination_state, TerminationState :: TimedOut ) {
292+ break ;
293+ }
202294 }
203295 insta:: assert_snapshot!( e2e. name. as_str( ) , e2e_outputs) ;
204296 }
@@ -212,9 +304,10 @@ fn main() {
212304
213305 let tests_dir = std:: env:: current_dir ( ) . unwrap ( ) . join ( "tests" ) ;
214306
215- insta:: glob!( tests_dir, "e2e_snapshots/fixtures/*" , |case_path| run_case(
216- & tmp_dir_path,
217- case_path,
218- filter. as_deref( )
219- ) ) ;
307+ // Create tokio runtime for async operations
308+ let runtime = tokio:: runtime:: Runtime :: new ( ) . unwrap ( ) ;
309+
310+ insta:: glob!( tests_dir, "e2e_snapshots/fixtures/*" , |case_path| {
311+ run_case( & runtime, & tmp_dir_path, case_path, filter. as_deref( ) )
312+ } ) ;
220313}
0 commit comments