Skip to content

Commit f17aab2

Browse files
branchseerclaude
andauthored
feat(e2e): add 10s timeout for test steps (#103)
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 <noreply@anthropic.com>
1 parent 493e67b commit f17aab2

File tree

1 file changed

+117
-24
lines changed
  • crates/vite_task_bin/tests/e2e_snapshots

1 file changed

+117
-24
lines changed

crates/vite_task_bin/tests/e2e_snapshots/main.rs

Lines changed: 117 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ mod redact;
33
use 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

1212
use copy_dir::copy_dir;
1313
use redact::redact_e2e_output;
14+
use tokio::{
15+
io::{AsyncReadExt, AsyncWriteExt},
16+
process::Command,
17+
};
1418
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf};
1519
use vite_str::Str;
1620
use 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

Comments
 (0)