Skip to content

Commit 06cf754

Browse files
committed
feat: run command with fspy
1 parent 7f653fe commit 06cf754

19 files changed

Lines changed: 603 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ jobs:
216216
- name: Run CLI E2E tests
217217
if: ${{ matrix.os == 'windows-latest' }}
218218
run: |
219-
RUST_BACKTRACE=1 pnpm -r snap-test
219+
RUST_BACKTRACE=1 pnpm --filter=@voidzero-dev/vite-plus test && pnpm -r snap-test
220220
git diff --exit-code
221221
222222
install-e2e-test:

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ criterion = { version = "0.7", features = ["html_reports"] }
4141
crossterm = { version = "0.29.0", features = ["event-stream"] }
4242
directories = "6.0.0"
4343
flate2 = "1.0.35"
44+
fspy = { git = "https://github.com/voidzero-dev/vite-task", rev = "d4fa5ecfa06cded665fd235db99b5ba9cfe43ab4" }
4445
futures-util = "0.3.31"
4546
hex = "0.4.3"
4647
httpmock = "0.7"
@@ -64,6 +65,7 @@ thiserror = "2"
6465
tokio = "1.48.0"
6566
tracing = "0.1.41"
6667
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "serde"] }
68+
vite_command = { path = "crates/vite_command" }
6769
vite_error = { path = "crates/vite_error" }
6870
vite_glob = { git = "https://github.com/voidzero-dev/vite-task", rev = "d4fa5ecfa06cded665fd235db99b5ba9cfe43ab4" }
6971
vite_install = { path = "crates/vite_install" }

crates/vite_command/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "vite_command"
3+
version = "0.0.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
rust-version.workspace = true
8+
9+
[dependencies]
10+
fspy = { workspace = true }
11+
tokio = { workspace = true }
12+
tracing = { workspace = true }
13+
vite_error = { workspace = true }
14+
vite_path = { workspace = true }
15+
which = { workspace = true, features = ["tracing"] }
16+
17+
[target.'cfg(not(target_os = "windows"))'.dependencies]
18+
nix = { workspace = true }
19+
20+
[dev-dependencies]
21+
tempfile = { workspace = true }
22+
23+
[lints]
24+
workspace = true

crates/vite_command/src/lib.rs

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
use std::{
2+
collections::HashMap,
3+
ffi::OsStr,
4+
process::{ExitStatus, Stdio},
5+
};
6+
7+
use fspy::AccessMode;
8+
use tokio::process::Command;
9+
use vite_error::Error;
10+
use vite_path::{AbsolutePath, RelativePathBuf};
11+
12+
/// Result of running a command with fspy tracking.
13+
#[derive(Debug)]
14+
pub struct FspyCommandResult {
15+
/// The termination status of the command.
16+
pub status: ExitStatus,
17+
/// The path accesses of the command.
18+
pub path_accesses: HashMap<RelativePathBuf, AccessMode>,
19+
}
20+
21+
/// Run a command with the given bin name, arguments, environment variables, and current working directory.
22+
///
23+
/// # Arguments
24+
///
25+
/// * `bin_name`: The name of the binary to run.
26+
/// * `args`: The arguments to pass to the binary.
27+
/// * `envs`: The custom environment variables to set for the command, will be merged with the system environment variables.
28+
/// * `cwd`: The current working directory for the command.
29+
///
30+
/// # Returns
31+
///
32+
/// Returns the exit status of the command.
33+
pub async fn run_command<I, S>(
34+
bin_name: &str,
35+
args: I,
36+
envs: &HashMap<String, String>,
37+
cwd: impl AsRef<AbsolutePath>,
38+
) -> Result<ExitStatus, Error>
39+
where
40+
I: IntoIterator<Item = S>,
41+
S: AsRef<OsStr>,
42+
{
43+
// Resolve the command path using which crate
44+
// If PATH is provided in envs, use which_in to search in custom paths
45+
// Otherwise, use which to search in system PATH
46+
let paths = envs.get("PATH");
47+
let cwd = cwd.as_ref();
48+
let bin_path = which::which_in(bin_name, paths, cwd)
49+
.map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?;
50+
51+
let mut cmd = Command::new(bin_path);
52+
cmd.args(args)
53+
.envs(envs)
54+
.current_dir(cwd)
55+
.stdin(Stdio::inherit())
56+
.stdout(Stdio::inherit())
57+
.stderr(Stdio::inherit());
58+
59+
// fix stdio streams on unix
60+
#[cfg(unix)]
61+
unsafe {
62+
cmd.pre_exec(|| {
63+
fix_stdio_streams();
64+
Ok(())
65+
});
66+
}
67+
68+
let status = cmd.status().await?;
69+
Ok(status)
70+
}
71+
72+
/// Run a command with fspy tracking.
73+
///
74+
/// # Arguments
75+
///
76+
/// * `bin_name`: The name of the binary to run.
77+
/// * `args`: The arguments to pass to the binary.
78+
/// * `envs`: The custom environment variables to set for the command.
79+
/// * `cwd`: The current working directory for the command.
80+
///
81+
/// # Returns
82+
///
83+
/// Returns a FspyCommandResult containing the exit status and path accesses.
84+
pub async fn run_command_with_fspy<I, S>(
85+
bin_name: &str,
86+
args: I,
87+
envs: &HashMap<String, String>,
88+
cwd: impl AsRef<AbsolutePath>,
89+
) -> Result<FspyCommandResult, Error>
90+
where
91+
I: IntoIterator<Item = S>,
92+
S: AsRef<OsStr>,
93+
{
94+
let cwd = cwd.as_ref();
95+
let mut cmd = fspy::Command::new(bin_name);
96+
cmd.args(args)
97+
// set system environment variables first
98+
.envs(std::env::vars_os())
99+
// then set custom environment variables
100+
.envs(envs)
101+
.current_dir(cwd)
102+
.stdin(Stdio::inherit())
103+
.stdout(Stdio::inherit())
104+
.stderr(Stdio::inherit());
105+
106+
// fix stdio streams on unix
107+
#[cfg(unix)]
108+
unsafe {
109+
cmd.pre_exec(|| {
110+
fix_stdio_streams();
111+
Ok(())
112+
});
113+
}
114+
115+
let child = cmd.spawn().await.map_err(|e| Error::Anyhow(e.into()))?;
116+
let termination = child.wait_handle.await?;
117+
118+
let mut path_accesses = HashMap::<RelativePathBuf, AccessMode>::new();
119+
for access in termination.path_accesses.iter() {
120+
tracing::debug!("Path access: {:?}", access);
121+
let relative_path = access
122+
.path
123+
.strip_path_prefix(cwd, |strip_result| {
124+
let Ok(stripped_path) = strip_result else {
125+
return None;
126+
};
127+
if stripped_path.as_os_str().is_empty() {
128+
return None;
129+
}
130+
tracing::debug!("stripped_path: {:?}", stripped_path);
131+
Some(RelativePathBuf::new(stripped_path).map_err(|err| {
132+
Error::InvalidRelativePath { path: stripped_path.into(), reason: err }
133+
}))
134+
})
135+
.transpose()?;
136+
let Some(relative_path) = relative_path else {
137+
continue;
138+
};
139+
path_accesses.entry(relative_path).or_insert(access.mode);
140+
}
141+
142+
Ok(FspyCommandResult { status: termination.status, path_accesses })
143+
}
144+
145+
#[cfg(unix)]
146+
fn fix_stdio_streams() {
147+
// libuv may mark stdin/stdout/stderr as close-on-exec, which interferes with Rust's subprocess spawning.
148+
// As a workaround, we clear the FD_CLOEXEC flag on these file descriptors to prevent them from being closed when spawning child processes.
149+
//
150+
// For details see https://github.com/libuv/libuv/issues/2062
151+
// Fixed by reference from https://github.com/electron/electron/pull/15555
152+
153+
use std::os::fd::BorrowedFd;
154+
155+
use nix::{
156+
fcntl::{FcntlArg, FdFlag, fcntl},
157+
libc::{STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
158+
};
159+
160+
// Safe function to clear FD_CLOEXEC flag
161+
fn clear_cloexec(fd: BorrowedFd<'_>) {
162+
// Borrow RawFd as BorrowedFd to satisfy AsFd constraint
163+
if let Ok(flags) = fcntl(fd, FcntlArg::F_GETFD) {
164+
let mut fd_flags = FdFlag::from_bits_retain(flags);
165+
if fd_flags.contains(FdFlag::FD_CLOEXEC) {
166+
fd_flags.remove(FdFlag::FD_CLOEXEC);
167+
// Ignore errors: some fd may be closed
168+
let _ = fcntl(fd, FcntlArg::F_SETFD(fd_flags));
169+
}
170+
}
171+
}
172+
173+
// Clear FD_CLOEXEC on stdin, stdout, stderr
174+
clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) });
175+
clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDOUT_FILENO) });
176+
clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDERR_FILENO) });
177+
}
178+
179+
#[cfg(test)]
180+
mod tests {
181+
use tempfile::{TempDir, tempdir};
182+
use vite_path::AbsolutePathBuf;
183+
184+
use super::*;
185+
186+
fn create_temp_dir() -> TempDir {
187+
tempdir().expect("Failed to create temp directory")
188+
}
189+
190+
mod run_command_tests {
191+
192+
use super::*;
193+
194+
#[tokio::test]
195+
async fn test_run_command_and_find_binary_path() {
196+
let temp_dir = create_temp_dir();
197+
let temp_dir_path =
198+
AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())
199+
.unwrap();
200+
let envs = HashMap::from([(
201+
"PATH".to_string(),
202+
std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(),
203+
)]);
204+
let result = run_command("npm", &["--version"], &envs, &temp_dir_path).await;
205+
assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result);
206+
}
207+
208+
#[tokio::test]
209+
async fn test_run_command_and_not_find_binary_path() {
210+
let temp_dir = create_temp_dir();
211+
let temp_dir_path =
212+
AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())
213+
.unwrap();
214+
let envs = HashMap::from([(
215+
"PATH".to_string(),
216+
std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(),
217+
)]);
218+
let result = run_command("npm-not-exists", &["--version"], &envs, &temp_dir_path).await;
219+
assert!(result.is_err(), "Should not find binary path, but got: {:?}", result);
220+
assert_eq!(
221+
result.unwrap_err().to_string(),
222+
"Cannot find binary path for command 'npm-not-exists'"
223+
);
224+
}
225+
}
226+
227+
mod run_command_with_fspy_tests {
228+
use super::*;
229+
230+
#[tokio::test]
231+
async fn test_run_command_with_fspy() {
232+
let temp_dir = create_temp_dir();
233+
let temp_dir_path =
234+
AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())
235+
.unwrap();
236+
let envs = HashMap::from([(
237+
"PATH".to_string(),
238+
std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(),
239+
)]);
240+
let result =
241+
run_command_with_fspy("node", &["-p", "process.cwd()"], &envs, &temp_dir_path)
242+
.await;
243+
assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result);
244+
let cmd_result = result.unwrap();
245+
assert!(cmd_result.status.success());
246+
}
247+
248+
#[tokio::test]
249+
async fn test_run_command_with_fspy_and_capture_path_accesses() {
250+
let temp_dir = create_temp_dir();
251+
let temp_dir_path =
252+
AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())
253+
.unwrap();
254+
let envs = HashMap::from([(
255+
"PATH".to_string(),
256+
std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(),
257+
)]);
258+
// Test with a filter that only accepts paths containing "package.json"
259+
let result = run_command_with_fspy(
260+
"node",
261+
&["-p", "fs.writeFileSync(path.join(process.cwd(), 'package.json'), '{}');'done'"],
262+
&envs,
263+
&temp_dir_path,
264+
)
265+
.await;
266+
assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result);
267+
let cmd_result = result.unwrap();
268+
assert!(cmd_result.status.success());
269+
eprintln!("cmd_result: {:?}", cmd_result);
270+
// Verify one path containing "package.json" is included
271+
assert_eq!(cmd_result.path_accesses.len(), 1);
272+
// FIXME: Windows detect wrong access mode for package.json
273+
// https://app.graphite.com/github/pr/voidzero-dev/vite-task/24/test-spy-write-file-on-windows#comment-PRRC_kwDOQCOitc6V0f-6
274+
// assert_eq!(
275+
// cmd_result.path_accesses.get(&RelativePathBuf::new("package.json").unwrap()),
276+
// Some(&AccessMode::Write)
277+
// );
278+
assert!(
279+
cmd_result
280+
.path_accesses
281+
.get(&RelativePathBuf::new("package.json").unwrap())
282+
.is_some()
283+
);
284+
}
285+
286+
#[tokio::test]
287+
async fn test_run_command_with_fspy_and_not_find_binary_path() {
288+
let temp_dir = create_temp_dir();
289+
let temp_dir_path =
290+
AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())
291+
.unwrap();
292+
let envs = HashMap::from([(
293+
"PATH".to_string(),
294+
std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(),
295+
)]);
296+
let result =
297+
run_command_with_fspy("npm-not-exists", &["--version"], &envs, &temp_dir_path)
298+
.await;
299+
assert!(result.is_err(), "Should not find binary path, but got: {:?}", result);
300+
assert!(
301+
result
302+
.err()
303+
.unwrap()
304+
.to_string()
305+
.contains("could not resolve the full path of program '\"npm-not-exists\"'")
306+
);
307+
}
308+
}
309+
}

0 commit comments

Comments
 (0)