Skip to content

Commit bf60d66

Browse files
branchseerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent e164719 commit bf60d66

File tree

1 file changed

+107
-25
lines changed
  • crates/vite_task_bin/tests/e2e_snapshots

1 file changed

+107
-25
lines changed

crates/vite_task_bin/tests/e2e_snapshots/main.rs

Lines changed: 107 additions & 25 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,98 @@ 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()
187-
};
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());
188195

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());
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
192203
}
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+
let mut timed_out = false;
217+
let mut exit_status: Option<std::process::ExitStatus> = None;
218+
219+
let timeout = tokio::time::sleep(STEP_TIMEOUT);
220+
tokio::pin!(timeout);
221+
222+
loop {
223+
let mut stdout_chunk = [0u8; 8192];
224+
let mut stderr_chunk = [0u8; 8192];
225+
226+
tokio::select! {
227+
result = stdout_handle.read(&mut stdout_chunk), if !stdout_done => {
228+
match result {
229+
Ok(0) => stdout_done = true,
230+
Ok(n) => stdout_buf.extend_from_slice(&stdout_chunk[..n]),
231+
Err(_) => stdout_done = true,
232+
}
233+
}
234+
result = stderr_handle.read(&mut stderr_chunk), if !stderr_done => {
235+
match result {
236+
Ok(0) => stderr_done = true,
237+
Ok(n) => stderr_buf.extend_from_slice(&stderr_chunk[..n]),
238+
Err(_) => stderr_done = true,
239+
}
240+
}
241+
result = child.wait(), if exit_status.is_none() => {
242+
exit_status = Some(result.unwrap());
243+
}
244+
_ = &mut timeout, if !timed_out => {
245+
// Timeout - kill the process
246+
let _ = child.kill().await;
247+
timed_out = true;
248+
}
249+
}
250+
251+
// Exit conditions:
252+
// 1. Process exited and all output drained
253+
// 2. Timed out and all output drained (after kill, pipes close)
254+
if (exit_status.is_some() || timed_out) && stdout_done && stderr_done {
255+
break;
256+
}
257+
}
258+
259+
// Format output
260+
if timed_out {
261+
e2e_outputs.push_str("[timeout]");
262+
} else if let Some(status) = exit_status {
263+
let exit_code = status.code().unwrap_or(-1);
264+
if exit_code != 0 {
265+
e2e_outputs.push_str(format!("[{}]", exit_code).as_str());
266+
}
267+
}
268+
193269
e2e_outputs.push_str("> ");
194270
e2e_outputs.push_str(step.cmd());
195271
e2e_outputs.push('\n');
196272

197-
let stdout = String::from_utf8(output.stdout).unwrap();
198-
let stderr = String::from_utf8(output.stderr).unwrap();
273+
let stdout = String::from_utf8_lossy(&stdout_buf).into_owned();
274+
let stderr = String::from_utf8_lossy(&stderr_buf).into_owned();
199275
e2e_outputs.push_str(&redact_e2e_output(stdout, e2e_stage_path_str));
200276
e2e_outputs.push_str(&redact_e2e_output(stderr, e2e_stage_path_str));
201277
e2e_outputs.push('\n');
278+
279+
// Skip remaining steps if timed out
280+
if timed_out {
281+
break;
282+
}
202283
}
203284
insta::assert_snapshot!(e2e.name.as_str(), e2e_outputs);
204285
}
@@ -212,9 +293,10 @@ fn main() {
212293

213294
let tests_dir = std::env::current_dir().unwrap().join("tests");
214295

215-
insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| run_case(
216-
&tmp_dir_path,
217-
case_path,
218-
filter.as_deref()
219-
));
296+
// Create tokio runtime for async operations
297+
let runtime = tokio::runtime::Runtime::new().unwrap();
298+
299+
insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| {
300+
run_case(&runtime, &tmp_dir_path, case_path, filter.as_deref())
301+
});
220302
}

0 commit comments

Comments
 (0)