Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions crates/fspy/tests/oxlint.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
mod test_utils;

use std::{env::vars_os, process::Stdio};
use std::{env::vars_os, ffi::OsString};

use fspy::{AccessMode, PathAccessIterable};
use test_log::test;

/// Find the oxlint executable in test_bins
fn find_oxlint() -> std::path::PathBuf {
let test_bins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
/// Get the test_bins/.bin directory path
fn test_bins_bin_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("vite_task_bin")
.join("test_bins")
.join("node_modules")
.join(".bin");
.join(".bin")
}

/// Find the oxlint executable in test_bins
fn find_oxlint() -> std::path::PathBuf {
let test_bins_dir = test_bins_bin_dir();
which::which_in("oxlint", Some(&test_bins_dir), std::env::current_dir().unwrap())
.expect("oxlint not found in test_bins/node_modules/.bin")
}

async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<PathAccessIterable> {
let oxlint_path = find_oxlint();
let mut command = fspy::Command::new(&oxlint_path);
command.args(args).stdout(Stdio::null()).stderr(Stdio::null()).envs(vars_os()).current_dir(dir);

// Build PATH with test_bins/.bin prepended so oxlint can find tsgolint
let test_bins_dir = test_bins_bin_dir();
let new_path = if let Some(existing_path) = std::env::var_os("PATH") {
let mut paths = vec![test_bins_dir.as_os_str().to_owned()];
paths.extend(std::env::split_paths(&existing_path).map(|p| p.into_os_string()));
std::env::join_paths(paths)?
} else {
OsString::from(&test_bins_dir)
};

command
.args(args)
.envs(vars_os().filter(|(k, _)| !k.eq_ignore_ascii_case("PATH")))
.env("PATH", new_path)
.current_dir(dir);

let child = command.spawn().await?;
let termination = child.wait_handle.await?;
Expand Down Expand Up @@ -54,13 +73,44 @@ async fn oxlint_reads_directory() -> anyhow::Result<()> {
// on macOS, tmpdir.path() may be a symlink, so we need to canonicalize it
let tmpdir_path = std::fs::canonicalize(tmpdir.path())?;

let js_file = tmpdir.path().join("test.js");
std::fs::write(&js_file, "console.log('hello');")?;

let accesses = track_oxlint(&tmpdir_path, &[]).await?;

// Check that oxlint read the directory to find JS files
// This is the key check - if READ_DIR is not tracked, cache won't detect new files
test_utils::assert_contains(&accesses, &tmpdir_path, AccessMode::READ_DIR);
Ok(())
}

#[test(tokio::test)]
async fn oxlint_type_aware() -> anyhow::Result<()> {
let tmpdir = tempfile::tempdir()?;
// on macOS, tmpdir.path() may be a symlink, so we need to canonicalize it
let tmpdir_path = std::fs::canonicalize(tmpdir.path())?;

// Create a simple TypeScript file
let ts_file = tmpdir_path.join("index.ts");
std::fs::write(
&ts_file,
r#"
import type { Foo } from './types';
declare const _foo: Foo;
"#,
)?;

// Run oxlint without --type-aware first
let accesses = track_oxlint(&tmpdir_path, &[""]).await?;
let access_to_types_ts = accesses.iter().find(|access| {
let os_str = access.path.to_cow_os_str();
os_str.as_encoded_bytes().ends_with(b"\\types.ts")
|| os_str.as_encoded_bytes().ends_with(b"/types.ts")
});
assert_eq!(access_to_types_ts, None, "oxlint should not read types.ts without --type-aware");

// Run oxlint with --type-aware to enable type-aware linting
let accesses = track_oxlint(&tmpdir_path, &["--type-aware"]).await?;

// Check that oxlint read types.ts
test_utils::assert_contains(&accesses, &tmpdir_path.join("types.ts"), AccessMode::READ);

Ok(())
}
18 changes: 16 additions & 2 deletions crates/fspy/tests/rust_std.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,25 @@ async fn open_write() -> anyhow::Result<()> {

#[test(tokio::test)]
async fn readdir() -> anyhow::Result<()> {
let accesses = track_child!((), |(): ()| {
let tmpdir = tempfile::tempdir()?;
let tmpdir_path = std::fs::canonicalize(tmpdir.path())?;
// Reading a non-existent directory results in different tracked accesses on different platforms:
// - Windows: READ, because the NT APIs open the directory as handle just like files (NtCreateFile/NtOpenFile),
// and if that fails, not read dir call (NtQueryDirectoryFile/NtQueryDirectoryFileEx) is made.
// - macOS/Linux:
// - opendir results in a read_dir access. This call is directly made without trying to open the directory as a fd first.
// - open + fopendir results in READ access, because open would fail with ENOENT, and fopendir is not called.
//
// This difference is acceptable because both will result in a "not found" fingerprint in vite-task.
// To keep the test consistent across platforms, we create the directory first.
std::fs::create_dir(tmpdir.path().join("hello_dir"))?;

let accesses = track_child!(tmpdir_path.to_str().unwrap().to_owned(), |tmpdir_path: String| {
std::env::set_current_dir(tmpdir_path).unwrap();
let _ = std::fs::read_dir("hello_dir");
})
.await?;
assert_contains(&accesses, current_dir()?.join("hello_dir").as_path(), AccessMode::READ_DIR);
assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::READ_DIR);

Ok(())
}
Expand Down
10 changes: 8 additions & 2 deletions crates/fspy/tests/rust_tokio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,21 @@ async fn open_write() -> anyhow::Result<()> {

#[test(tokio::test)]
async fn readdir() -> anyhow::Result<()> {
let accesses = track_child!((), |(): ()| {
let tmpdir = tempfile::tempdir()?;
let tmpdir_path = std::fs::canonicalize(tmpdir.path())?;

std::fs::create_dir(tmpdir.path().join("hello_dir"))?;

let accesses = track_child!(tmpdir_path.to_str().unwrap().to_owned(), |tmpdir_path: String| {
std::env::set_current_dir(tmpdir_path).unwrap();
tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(
async {
let _ = tokio::fs::read_dir("hello_dir").await;
},
);
})
.await?;
assert_contains(&accesses, current_dir()?.join("hello_dir").as_path(), AccessMode::READ_DIR);
assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::READ_DIR);

Ok(())
}
Expand Down
16 changes: 12 additions & 4 deletions crates/fspy_preload_windows/src/windows/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,24 @@ impl ToAbsolutePath for POBJECT_ATTRIBUTES {
self,
f: F,
) -> winsafe::SysResult<R> {
let filename_str = unsafe { get_u16_str(&*(*self).ObjectName) };
let filename_str = if let Some(object_name) = unsafe { (*self).ObjectName.as_ref() } {
unsafe { get_u16_str(object_name) }
} else {
U16Str::from_slice(&[])
};
let filename_slice = filename_str.as_slice();
let is_absolute = (filename_slice.get(0) == Some(&b'\\'.into())
&& filename_slice.get(1) == Some(&b'\\'.into())) // \\...
let is_absolute = filename_slice.get(0) == Some(&b'\\'.into()) // \...
|| filename_slice.get(1) == Some(&b':'.into()); // C:...

if is_absolute {
if !is_absolute {
let Ok(mut root_dir) = (unsafe { get_path_name((*self).RootDirectory) }) else {
return f(None);
};
// If filename is empty, just use root_dir directly
if filename_str.is_empty() {
let root_dir_str = U16Str::from_slice(&root_dir);
return f(Some(root_dir_str));
}
let root_dir_cstr = {
root_dir.push(0);
unsafe { U16CStr::from_ptr_str(root_dir.as_ptr()) }
Expand Down
15 changes: 11 additions & 4 deletions crates/fspy_preload_windows/src/windows/detour.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ impl<T: Copy> Detour<T> {
Detour { symbol_name, target: UnsafeCell::new(unsafe { transmute_copy(&target) }), new }
}

#[expect(dead_code)]
pub const unsafe fn dynamic(symbol_name: &'static CStr, new: T) -> Self {
Detour { symbol_name, target: UnsafeCell::new(null_mut()), new }
}
Expand Down Expand Up @@ -52,15 +51,18 @@ pub struct DetourAny {
pub struct AttachContext {
kernelbase: HMODULE,
kernel32: HMODULE,
ntdll: HMODULE,
}

impl AttachContext {
pub fn new() -> Self {
let kernelbase = unsafe { LoadLibraryA(c"kernelbase".as_ptr()) };
let kernel32 = unsafe { LoadLibraryA(c"kernel32".as_ptr()) };
let ntdll = unsafe { LoadLibraryA(c"ntdll".as_ptr()) };
assert_ne!(kernelbase, null_mut());
assert_ne!(kernel32, null_mut());
Self { kernelbase, kernel32 }
assert_ne!(ntdll, null_mut());
Self { kernelbase, kernel32, ntdll }
}
}

Expand All @@ -74,9 +76,14 @@ impl DetourAny {
unsafe { *self.target = symbol_in_kernelbase.cast() };
} else {
if unsafe { *self.target }.is_null() {
// dynamic symbol
// dynamic symbol - look up from kernel32 or ntdll
let symbol_in_kernel32 = unsafe { GetProcAddress(ctx.kernel32, symbol_name) };
unsafe { *self.target = symbol_in_kernel32.cast() };
if !symbol_in_kernel32.is_null() {
unsafe { *self.target = symbol_in_kernel32.cast() };
} else {
let symbol_in_ntdll = unsafe { GetProcAddress(ctx.ntdll, symbol_name) };
unsafe { *self.target = symbol_in_ntdll.cast() };
}
}
}
if unsafe { *self.target }.is_null() {
Expand Down
50 changes: 50 additions & 0 deletions crates/fspy_preload_windows/src/windows/detours/nt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,55 @@ static DETOUR_NT_QUERY_DIRECTORY_FILE: Detour<
})
};

// NtQueryDirectoryFileEx is not in ntapi crate, so we define it here.
// https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntquerydirectoryfileex
type NtQueryDirectoryFileExFn = unsafe extern "system" fn(
file_handle: HANDLE,
event: HANDLE,
apc_routine: PIO_APC_ROUTINE,
apc_context: PVOID,
io_status_block: PIO_STATUS_BLOCK,
file_information: PVOID,
length: ULONG,
file_information_class: FILE_INFORMATION_CLASS,
query_flags: ULONG,
file_name: PUNICODE_STRING,
) -> NTSTATUS;

static DETOUR_NT_QUERY_DIRECTORY_FILE_EX: Detour<NtQueryDirectoryFileExFn> = unsafe {
Detour::dynamic(c"NtQueryDirectoryFileEx", {
unsafe extern "system" fn new_fn(
file_handle: HANDLE,
event: HANDLE,
apc_routine: PIO_APC_ROUTINE,
apc_context: PVOID,
io_status_block: PIO_STATUS_BLOCK,
file_information: PVOID,
length: ULONG,
file_information_class: FILE_INFORMATION_CLASS,
query_flags: ULONG,
file_name: PUNICODE_STRING,
) -> NTSTATUS {
unsafe { handle_open(AccessMode::READ_DIR, file_handle) };
unsafe {
(DETOUR_NT_QUERY_DIRECTORY_FILE_EX.real())(
file_handle,
event,
apc_routine,
apc_context,
io_status_block,
file_information,
length,
file_information_class,
query_flags,
file_name,
)
}
}
new_fn
})
};

pub const DETOURS: &[DetourAny] = &[
DETOUR_NT_CREATE_FILE.as_any(),
DETOUR_NT_OPEN_FILE.as_any(),
Expand All @@ -294,4 +343,5 @@ pub const DETOURS: &[DetourAny] = &[
DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT.as_any(),
DETOUR_NT_QUERY_INFORMATION_BY_NAME.as_any(),
DETOUR_NT_QUERY_DIRECTORY_FILE.as_any(),
DETOUR_NT_QUERY_DIRECTORY_FILE_EX.as_any(),
];
12 changes: 10 additions & 2 deletions crates/fspy_preload_windows/src/windows/winapi_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,16 @@ pub fn ck_long(val: c_long) -> winsafe::SysResult<()> {
}

pub unsafe fn get_u16_str(ustring: &UNICODE_STRING) -> &U16Str {
let chars =
unsafe { slice::from_raw_parts((*ustring).Buffer, (*ustring).Length.try_into().unwrap()) };
// https://learn.microsoft.com/en-us/windows/win32/api/subauth/ns-subauth-unicode_string
// UNICODE_STRING.Length is in bytes
let u16_count = ustring.Length / 2;
let chars: &[u16] = if u16_count == 0 {
// If length is zero, we can't use slice::from_raw_parts as it requires a non-null pointer but
// Buffer may be null in that case.
&[]
} else {
unsafe { slice::from_raw_parts((*ustring).Buffer, u16_count.try_into().unwrap()) }
};
match U16CStr::from_slice_truncate(chars) {
Ok(ok) => ok.as_ustr(),
Err(_) => chars.into(),
Expand Down
2 changes: 1 addition & 1 deletion crates/fspy_shared/src/ipc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ impl Debug for AccessMode {
}
}

#[derive(Encode, BorrowDecode, Debug, Clone, Copy)]
#[derive(Encode, BorrowDecode, Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathAccess<'a> {
pub mode: AccessMode,
pub path: &'a NativeStr,
Expand Down
1 change: 1 addition & 0 deletions crates/vite_task_bin/test_bins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@yarnpkg/shell": "catalog:",
"cross-env": "^10.1.0",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"vite-task-test-bins": "link:"
}
}
6 changes: 3 additions & 3 deletions crates/vite_task_bin/tests/test_snapshots/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::{
use copy_dir::copy_dir;
use redact::{redact_e2e_output, redact_snapshot};
use tokio::runtime::Runtime;
use vite_path::{AbsolutePath, RelativePathBuf};
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf};
use vite_str::Str;
use vite_task::{CLIArgs, Session};
use vite_task_bin::CustomTaskSubcommand;
Expand Down Expand Up @@ -209,13 +209,13 @@ fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, fixture_path: &Path) {
fn test_snapshots() {
let tokio_runtime = Runtime::new().unwrap();
let tmp_dir = tempfile::tempdir().unwrap();
let tmp_dir_path = AbsolutePath::new(tmp_dir.path()).unwrap();
let tmp_dir_path = AbsolutePathBuf::new(tmp_dir.path().canonicalize().unwrap()).unwrap();

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

insta::glob!(tests_dir, "test_snapshots/fixtures/*", |case_path| run_case(
&tokio_runtime,
tmp_dir_path,
&tmp_dir_path,
case_path
));
}
3 changes: 2 additions & 1 deletion crates/vite_task_bin/tests/test_snapshots/redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ fn redact_string(s: &mut String, redactions: &[(&str, &str)]) {

pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();

redact_string(
&mut output,
&[(workspace_root, "<workspace>"), (manifest_dir.as_str(), "<manifest_dir>")],
);

// Redact durations like "123ms" or "1.23s" to "<duration>ms" or "<duration>s"
let duration_regex = regex::Regex::new(r"\d+(\.\d+)?(ms|s)").unwrap();
output = duration_regex.replace_all(&output, "<duration>$2").into_owned();
output = duration_regex.replace_all(&output, "<duration>").into_owned();

// Redact thread counts like "using 10 threads" to "using <n> threads"
let thread_regex = regex::Regex::new(r"using \d+ threads").unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ input_file: crates/vite_task_bin/tests/test_snapshots/fixtures/cache-keys
---
> vite lint
Found 0 warnings and 0 errors.
Finished in <duration>ms on 0 files with 90 rules using <n> threads.
Finished in <duration> on 0 files with 90 rules using <n> threads.


> echo debugger > main.js
Expand All @@ -21,4 +21,4 @@ Finished in <duration>ms on 0 files with 90 rules using <n> threads.
help: Remove the debugger statement

Found 1 warning and 0 errors.
Finished in <duration>ms on 1 file with 90 rules using <n> threads.
Finished in <duration> on 1 file with 90 rules using <n> threads.
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/vite_task_bin/tests/test_snapshots/main.rs
assertion_line: 203
expression: e2e_outputs
input_file: crates/vite_task_bin/tests/test_snapshots/fixtures/exit-codes
---
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/vite_task_bin/tests/test_snapshots/main.rs
assertion_line: 203
expression: e2e_outputs
input_file: crates/vite_task_bin/tests/test_snapshots/fixtures/exit-codes
---
Expand Down
Loading