Skip to content

Commit 5826052

Browse files
committed
feat: run command with fspy
1 parent ac7173c commit 5826052

18 files changed

Lines changed: 657 additions & 19 deletions

File tree

.github/workflows/ci.yml

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"install-global-cli": "npm install -g ./packages/global",
1515
"typecheck": "tsc -b tsconfig.json",
1616
"lint": "vite lint",
17-
"test": "vite test run --config vite.config.ts && pnpm -r snap-test",
17+
"test": "vite test run && pnpm --filter=@voidzero-dev/vite-plus test && pnpm -r snap-test",
1818
"prepare": "husky"
1919
},
2020
"devDependencies": {

0 commit comments

Comments
 (0)