diff --git a/CHANGELOG.md b/CHANGELOG.md index b382b4834..4c27e5249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** A task's outputs now include the symbolic links it creates, like the links Nitro's dependency tracer makes in `.output/server/node_modules`, without an `output` glob that matches them. A cache hit restores them as links, and the next run is a cache hit if they are missing, as on a fresh clone. Previously a hit left them out, and removing one that the task had checked for was a cache miss ([#918](https://github.com/voidzero-dev/vite-task/pull/918)). - **Fixed** A cache hit now restores the empty folders a task made, like the `.astro/collections` folder that `astro build` leaves empty. A folder holding only such folders and files the task wrote, like `.astro`, may now be missing, as on a fresh clone, and the next run is still a cache hit that restores it. Previously a hit left empty folders missing, and removing a folder holding one was a cache miss ([#917](https://github.com/voidzero-dev/vite-task/pull/917)). - **Fixed** A task's outputs now include the files it renames into place, like Vitest's `fsModuleCache` entries written under a temporary name, every file in a folder it renames into place, like Vite's dependency optimizer renaming `deps_temp_*` to `deps`, and the hard links it creates. A cache hit restores them, and the next run is a cache hit if they are missing, as on a fresh clone. Previously they weren't restored, and removing one that the task read back was a cache miss ([#916](https://github.com/voidzero-dev/vite-task/pull/916)). - **Changed** When a task lists a folder, the files it wrote there and the folders holding only such files no longer count as entries of the listing: the next run is a cache hit if they are missing, as on a fresh clone, and the hit restores them. For example, `tsc` with no `include` lists the package root, which holds its `lib` output folder; removing `lib` used to be a cache miss. The folder's other entries still count ([#915](https://github.com/voidzero-dev/vite-task/pull/915)). diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs index a84ccd853..d55ce8fbb 100644 --- a/crates/fspy/src/unix/syscall_handler/mod.rs +++ b/crates/fspy/src/unix/syscall_handler/mod.rs @@ -112,6 +112,8 @@ impl_handler!( renameat2, #[cfg(target_arch = "x86_64")] link, linkat, + #[cfg(target_arch = "x86_64")] symlink, + symlinkat, execve, execveat, diff --git a/crates/fspy/src/unix/syscall_handler/namespace.rs b/crates/fspy/src/unix/syscall_handler/namespace.rs index 8f8a73715..2eccd1f4b 100644 --- a/crates/fspy/src/unix/syscall_handler/namespace.rs +++ b/crates/fspy/src/unix/syscall_handler/namespace.rs @@ -1,9 +1,11 @@ -//! Syscalls that make an entry at a path without opening it: mkdir, and -//! rename and hard link, which put an existing file or folder at a new path. -//! The path of a mkdir is recorded as a mkdir, whether or not the folder is -//! there already; the new path of a rename as a rename target, which may be -//! a whole folder; and that of a hard link, always a file, as a write. The -//! source of a rename needs no record. +//! Syscalls that make an entry at a path without opening it: mkdir, symlink, +//! and rename and hard link, which put an existing file or folder at a new +//! path. The path of a mkdir is recorded as a mkdir, whether or not the +//! folder is there already; the new path of a rename as a rename target, +//! which may be a whole folder; and that of a symlink, and of a hard link, +//! always a file, as a write. The source of a rename needs no record, and +//! neither does the target of a symlink, which is stored as text, not +//! accessed. use std::io; @@ -69,4 +71,21 @@ impl SyscallHandler { ) -> io::Result<()> { self.handle_path(caller, new_dir_fd, new, AccessMode::Write) } + + #[cfg(target_arch = "x86_64")] + pub(super) fn symlink( + &mut self, + caller: Caller, + (_target, link): (Ignored, CStrPtr), + ) -> io::Result<()> { + self.handle_path(caller, Fd::cwd(), link, AccessMode::Write) + } + + pub(super) fn symlinkat( + &mut self, + caller: Caller, + (_target, new_dir_fd, link): (Ignored, Fd, CStrPtr), + ) -> io::Result<()> { + self.handle_path(caller, new_dir_fd, link, AccessMode::Write) + } } diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 3940b4dc2..7430bd6a5 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -144,6 +144,34 @@ fn write_sync(runtime: &str) -> anyhow::Result<()> { Ok(()) } +#[test_case("node")] +#[ignore = "requires node"] +#[cfg_attr(not(target_os = "linux"), test_case("bun"))] +#[cfg_attr(not(target_os = "linux"), ignore = "requires node")] +#[cfg_attr(not(target_env = "musl"), test_case("deno"))] +#[cfg_attr(not(target_env = "musl"), ignore = "requires node")] +fn symlink_junction_sync(runtime: &str) -> anyhow::Result<()> { + let tmpdir = tempfile::tempdir()?; + // Long names, as in `rust_std::symlink_destination`. + let tmp_path = tmpdir.path().canonicalize()?; + let target = tmp_path.join("target"); + let link = tmp_path.join("link"); + std::fs::create_dir(&target)?; + // A junction on Windows, as nf3 makes them; a symlink elsewhere. + let accesses = track_script( + runtime, + "try { fs.symlinkSync(process.argv.at(-2), process.argv.at(-1), 'junction') } catch {}", + &[target.as_os_str(), link.as_os_str()], + )?; + #[cfg(unix)] + assert_contains(&accesses, &link, PathAccess::Write); + // A junction is a folder, recorded as a mkdir, with a mount point set on + // it. + #[cfg(windows)] + test_utils::assert_includes(&accesses, &link, PathAccess::Write); + Ok(()) +} + #[test_case("node")] #[ignore = "requires node"] #[cfg_attr(not(target_os = "linux"), test_case("bun"))] diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index d7776c3c2..ce4a251e9 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -69,6 +69,42 @@ async fn hard_link_destination() -> anyhow::Result<()> { Ok(()) } +#[test(tokio::test)] +async fn symlink_destination() -> anyhow::Result<()> { + let tmp_dir = tempfile::tempdir()?; + // On Windows the link is recorded from its handle, by its long name, while + // `TEMP` may hold an 8.3 short name, so name the links by the long name. + let tmp_path = tmp_dir.path().canonicalize()?; + fs::create_dir(tmp_path.join("folder"))?; + let tmp_path_str = tmp_path.to_str().unwrap().to_owned(); + let accesses = track_fn!(tmp_path_str, |tmp_path_str: String| { + let tmp_dir = std::path::Path::new(&tmp_path_str); + #[cfg(unix)] + { + std::os::unix::fs::symlink("file", tmp_dir.join("file_link")).unwrap(); + std::os::unix::fs::symlink("folder", tmp_dir.join("folder_link")).unwrap(); + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file("file", tmp_dir.join("file_link")).unwrap(); + std::os::windows::fs::symlink_dir("folder", tmp_dir.join("folder_link")).unwrap(); + } + }) + .await?; + for link in ["file_link", "folder_link"] { + let link = tmp_path.join(link); + #[cfg(unix)] + assert_contains(&accesses, &link, PathAccess::Write); + // `CreateSymbolicLinkW` creates the link with an open that lacks write + // access, recorded as a read, or as a mkdir for a folder link, before + // it sets the reparse point. + #[cfg(windows)] + test_utils::assert_includes(&accesses, &link, PathAccess::Write); + } + + Ok(()) +} + #[test(tokio::test)] async fn mkdir() -> anyhow::Result<()> { let tmp_dir = tempfile::tempdir()?; diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index c095de671..80a2d6400 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -111,6 +111,14 @@ async fn hard_link_destination() { assert_contains(&accesses, Path::new("/world"), fspy::PathAccess::Write); } +#[test(tokio::test)] +async fn symlink_destination() { + // The symlink fails, since `/hello` doesn't exist; the link is recorded + // anyway. + let accesses = track_test_bin(&["symlink", "target", "/hello/world"], None).await; + assert_contains(&accesses, Path::new("/hello/world"), fspy::PathAccess::Write); +} + #[test(tokio::test)] async fn open_relative() { let accesses = track_test_bin(&["open_read", "hello"], Some("/home")).await; diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index f47bb6837..485b269fa 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -62,6 +62,39 @@ pub fn assert_contains<'a>( ); } +/// Asserts that `expected_path` was accessed as `expected` (a variant such as +/// `PathAccess::Write`), whatever other accesses to it there were. +/// +/// # Panics +/// +/// Panics if the path was not accessed as `expected`. +#[expect( + clippy::allow_attributes, + reason = "allow attribute required for conditionally-used helper" +)] +#[allow(dead_code, reason = "not all test files use this helper")] +#[track_caller] +pub fn assert_includes<'a>( + accesses: &'a PathAccessIterable, + expected_path: &Path, + expected: fn(&'a IpcPath) -> PathAccess<'a>, +) { + let found = accesses.iter().any(|access| { + access.paths().any(|path| { + access == expected(path) + && path.strip_path_prefix(expected_path, |strip_result| { + strip_result.is_ok_and(|stripped| stripped.as_os_str().is_empty()) + }) + }) + }); + assert!( + found, + "Expected an access to {} of the expected kind. All accesses: {:?}", + expected_path.display(), + accesses.iter().collect::>() + ); +} + /// Asserts that `unexpected_path` was not accessed in any way. /// /// # Panics diff --git a/crates/fspy_preload_unix/src/interceptions/namespace.rs b/crates/fspy_preload_unix/src/interceptions/namespace.rs index c4e1fbf3c..7f33c7404 100644 --- a/crates/fspy_preload_unix/src/interceptions/namespace.rs +++ b/crates/fspy_preload_unix/src/interceptions/namespace.rs @@ -1,9 +1,11 @@ -//! Calls that make an entry at a path without opening it: mkdir, and rename -//! and hard link, which put an existing file or folder at a new path. The -//! path is recorded before the call, so a failed call is recorded too: as a -//! mkdir for a mkdir, whose folder may be there already; as a rename target -//! for a rename, which may move a whole folder; and as a write for a hard -//! link, always to a file. The source of a rename needs no record. +//! Calls that make an entry at a path without opening it: mkdir, symlink, +//! and rename and hard link, which put an existing file or folder at a new +//! path. The path is recorded before the call, so a failed call is recorded +//! too: as a mkdir for a mkdir, whose folder may be there already; as a +//! rename target for a rename, which may move a whole folder; and as a write +//! for a symlink, and for a hard link, always to a file. The source of a +//! rename needs no record, and neither does the target of a symlink, which +//! is stored as text, not accessed. use fspy_shared::ipc::AccessMode; use libc::{c_char, c_int, mode_t}; @@ -126,3 +128,23 @@ unsafe extern "C" fn linkat( // SAFETY: calling the original libc linkat() with the same arguments forwarded from the interposed function unsafe { linkat::original()(olddirfd, old, newdirfd, new, flags) } } + +intercept!(symlink: unsafe extern "C" fn(target: *const c_char, link: *const c_char) -> c_int); +unsafe extern "C" fn symlink(target: *const c_char, link: *const c_char) -> c_int { + // SAFETY: link is a valid C string pointer provided by the caller of the interposed function + unsafe { handle_open(fspy_nostd::CStr::from_ptr(link.cast()), AccessMode::Write) }; + // SAFETY: calling the original libc symlink() with the same arguments forwarded from the interposed function + unsafe { symlink::original()(target, link) } +} + +intercept!(symlinkat: unsafe extern "C" fn(target: *const c_char, newdirfd: c_int, link: *const c_char) -> c_int); +unsafe extern "C" fn symlinkat( + target: *const c_char, + newdirfd: c_int, + link: *const c_char, +) -> c_int { + // SAFETY: newdirfd is -1 or a valid file descriptor, and link is a valid C string, provided by the caller of the interposed function + unsafe { handle_open(PathAt::borrow_raw(newdirfd, link), AccessMode::Write) }; + // SAFETY: calling the original libc symlinkat() with the same arguments forwarded from the interposed function + unsafe { symlinkat::original()(target, newdirfd, link) } +} diff --git a/crates/fspy_preload_windows/Cargo.toml b/crates/fspy_preload_windows/Cargo.toml index e9f0dfd57..88f512fa3 100644 --- a/crates/fspy_preload_windows/Cargo.toml +++ b/crates/fspy_preload_windows/Cargo.toml @@ -26,7 +26,11 @@ winapi = { workspace = true, features = [ "memoryapi", "std", ] } -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_UI_Shell"] } +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_Ioctl", + "Win32_UI_Shell", +] } winsafe = { workspace = true, features = ["kernel"] } [target.'cfg(target_os = "windows")'.dev-dependencies] diff --git a/crates/fspy_preload_windows/src/windows/detours/nt.rs b/crates/fspy_preload_windows/src/windows/detours/nt.rs index e38da4e03..769f74f25 100644 --- a/crates/fspy_preload_windows/src/windows/detours/nt.rs +++ b/crates/fspy_preload_windows/src/windows/detours/nt.rs @@ -24,6 +24,7 @@ use winapi::{ }, um::winnt::{ACCESS_MASK, GENERIC_READ}, }; +use windows_sys::Win32::System::Ioctl::{FSCTL_SET_REPARSE_POINT, FSCTL_SET_REPARSE_POINT_EX}; use crate::windows::{ client::global_client, @@ -339,6 +340,66 @@ unsafe fn handle_destination(access_mode: AccessMode, file_information: PVOID, l unsafe { handle_open(access_mode, &raw mut object_attributes) }; } +// A reparse point set on a file or folder, which makes it a link, is recorded +// as a write before the call, so a failed call is recorded too, as `symlink` +// is on Unix. `CreateSymbolicLinkW` creates the link with an open that lacks +// write access, recorded as a read (or as a mkdir for a folder link), and +// sets its reparse point here. A junction, as libuv makes for Node's +// `fs.symlink` with `'junction'`, is a folder, recorded as a mkdir, with a +// mount point set here. +static DETOUR_NT_FS_CONTROL_FILE: Detour< + unsafe extern "system" fn( + file_handle: HANDLE, + event: HANDLE, + apc_routine: PIO_APC_ROUTINE, + apc_context: PVOID, + io_status_block: PIO_STATUS_BLOCK, + fs_control_code: ULONG, + input_buffer: PVOID, + input_buffer_length: ULONG, + output_buffer: PVOID, + output_buffer_length: ULONG, + ) -> NTSTATUS, +> = + // SAFETY: initializing Detour with the real NtFsControlFile function pointer + unsafe { + Detour::new(c"NtFsControlFile", ntapi::ntioapi::NtFsControlFile, { + 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, + fs_control_code: ULONG, + input_buffer: PVOID, + input_buffer_length: ULONG, + output_buffer: PVOID, + output_buffer_length: ULONG, + ) -> NTSTATUS { + if matches!(fs_control_code, FSCTL_SET_REPARSE_POINT | FSCTL_SET_REPARSE_POINT_EX) { + // SAFETY: intercepting reparse point creation to record the file it is set on before forwarding to real function + unsafe { handle_open(AccessMode::Write, file_handle) }; + } + // SAFETY: calling the original NtFsControlFile with all original arguments + unsafe { + (DETOUR_NT_FS_CONTROL_FILE.real())( + file_handle, + event, + apc_routine, + apc_context, + io_status_block, + fs_control_code, + input_buffer, + input_buffer_length, + output_buffer, + output_buffer_length, + ) + } + } + new_fn + }) + }; + static DETOUR_NT_OPEN_FILE: Detour< unsafe extern "system" fn( file_handle: PHANDLE, @@ -641,6 +702,7 @@ pub const DETOURS: &[DetourAny] = &[ DETOUR_NT_CREATE_FILE.as_any(), DETOUR_NT_OPEN_FILE.as_any(), DETOUR_NT_SET_INFORMATION_FILE.as_any(), + DETOUR_NT_FS_CONTROL_FILE.as_any(), DETOUR_NT_QUERY_ATTRIBUTES_FILE.as_any(), DETOUR_NT_FULL_QUERY_ATTRIBUTES_FILE.as_any(), DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT.as_any(), diff --git a/crates/fspy_test_bin/src/main.rs b/crates/fspy_test_bin/src/main.rs index 4bd6dbc50..d1a059a0d 100644 --- a/crates/fspy_test_bin/src/main.rs +++ b/crates/fspy_test_bin/src/main.rs @@ -57,6 +57,9 @@ fn main() { "hard_link" => { let _ = std::fs::hard_link(path, second_path()); } + "symlink" => { + let _ = std::os::unix::fs::symlink(path, second_path()); + } _ => panic!("unknown action: {action}"), } } diff --git a/crates/vt/src/session/execute/rebuildable.rs b/crates/vt/src/session/execute/rebuildable.rs index 162aa7fd5..d64837375 100644 --- a/crates/vt/src/session/execute/rebuildable.rs +++ b/crates/vt/src/session/execute/rebuildable.rs @@ -28,6 +28,9 @@ //! ignored. A mkdir record counts only for a folder that is empty after the //! run, whether or not the call made it, since it may have been there //! already: what any other folder holds decides whether it is rebuildable. +//! A Windows junction is made as a folder before its mount point is set, but +//! std reports it as a symlink, so it counts as written, like any symlink the +//! task made, and not as a folder. use std::{cmp::Reverse, fs, io}; diff --git a/crates/vt_bin/src/vtt/main.rs b/crates/vt_bin/src/vtt/main.rs index 44aa0b41c..00c221c16 100644 --- a/crates/vt_bin/src/vtt/main.rs +++ b/crates/vt_bin/src/vtt/main.rs @@ -30,7 +30,6 @@ mod small_dev_shm; mod stalled_remote_cache; mod stat_file; mod stat_many; -#[cfg(unix)] mod symlink; mod touch_file; mod write_file; @@ -84,10 +83,7 @@ fn main() { Ok(()) } "stat-many" => stat_many::run(&args[2..]), - #[cfg(unix)] "symlink" => symlink::run(&args[2..]), - #[cfg(not(unix))] - "symlink" => Err("vtt symlink is only supported on Unix".into()), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vt_bin/src/vtt/symlink.rs b/crates/vt_bin/src/vtt/symlink.rs index 2ab960572..e0172f41c 100644 --- a/crates/vt_bin/src/vtt/symlink.rs +++ b/crates/vt_bin/src/vtt/symlink.rs @@ -1,8 +1,20 @@ /// Create a symbolic link at `` that points to ``, like `ln -s`. +/// On Windows, the link is a directory link if ``, resolved from the +/// link's folder, is a directory. pub fn run(args: &[String]) -> Result<(), Box> { let [target, link] = args else { return Err("Usage: vtt symlink ".into()); }; + #[cfg(unix)] std::os::unix::fs::symlink(target, link)?; + #[cfg(windows)] + { + let link = std::path::Path::new(link); + if link.parent().is_some_and(|parent| parent.join(target).is_dir()) { + std::os::windows::fs::symlink_dir(target, link)?; + } else { + std::os::windows::fs::symlink_file(target, link)?; + } + } Ok(()) } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml index 422b82f62..80494cae7 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots.toml @@ -30,3 +30,36 @@ steps = [ "check.cjs", ], comment = "the restored output has the same links, not copies, and resolves the same versions" }, ] + +[[e2e]] +name = "nitro_auto_outputs_restore_links" +comment = """ +The same Nitro build with automatic input and output tracking only, no `input` or `output` globs. nf3 checks each path before linking it, so the links are among the paths the task read. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "build-auto", + ], comment = "first run — cache miss, Nitro builds .output/" }, + [ + "node", + "check.cjs", + ], + { argv = [ + "vtt", + "rm", + "-rf", + ".output", + ], comment = "remove .output/, as on a clean checkout" }, + { argv = [ + "vt", + "run", + "build-auto", + ], comment = "several inputs are missing and vt names whichever it finds first, so the miss reason is redacted", redact = { 'cache miss: .*, executing' = 'cache miss: , executing' } }, + { argv = [ + "node", + "check.cjs", + ], comment = "the output has the same links, not copies, and resolves the same versions" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md new file mode 100644 index 000000000..1d535c5de --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/snapshots/nitro_auto_outputs_restore_links.md @@ -0,0 +1,53 @@ +# nitro_auto_outputs_restore_links + +The same Nitro build with automatic input and output tracking only, no `input` or `output` globs. nf3 checks each path before linking it, so the links are among the paths the task read. + +## `vt run build-auto` + +first run — cache miss, Nitro builds .output/ + +``` +$ CONSOLA_LEVEL=0 nitro build +``` + +## `node check.cjs` + +``` +tslib: link to .nf3/tslib@2.8.1 +dep-a/node_modules/tslib: link to ../../.nf3/tslib@1.14.1 +dep-b/node_modules/tslib: link to ../../.nf3/tslib@2.8.1 +dep-a: tslib 1.14.1 +dep-b: tslib 2.8.1 +hoisted: tslib 2.8.1 +``` + +## `vtt rm -rf .output` + +remove .output/, as on a clean checkout + +``` +``` + +## `vt run build-auto` + +several inputs are missing and vt names whichever it finds first, so the miss reason is redacted + +``` +$ CONSOLA_LEVEL=0 nitro build ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `node check.cjs` + +the output has the same links, not copies, and resolves the same versions + +``` +tslib: link to .nf3/tslib@2.8.1 +dep-a/node_modules/tslib: link to ../../.nf3/tslib@1.14.1 +dep-b/node_modules/tslib: link to ../../.nf3/tslib@2.8.1 +dep-a: tslib 1.14.1 +dep-b: tslib 2.8.1 +hoisted: tslib 2.8.1 +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json index f53f3a9da..113c8977f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/nitro_build_cache/vite-task.json @@ -6,6 +6,10 @@ "input": ["routes/**", "nitro.config.ts"], "output": [".output/**"] } + }, + "build-auto": { + "command": "CONSOLA_LEVEL=0 nitro build", + "cache": true } } } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/out/target.txt @@ -0,0 +1 @@ +hello diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json new file mode 100644 index 000000000..b63abe658 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/package.json @@ -0,0 +1,4 @@ +{ + "name": "symlink-in-output", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml new file mode 100644 index 000000000..0920bba7e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots.toml @@ -0,0 +1,27 @@ +[[e2e]] +name = "symlink_in_output" +comment = """ +The task links `out/link` to `out/target.txt`, a file in the fixture, like a build tool linking a dependency into its output. +""" +steps = [ + [ + "vt", + "run", + "build", + ], + { argv = [ + "vtt", + "rm", + "out/link", + ], comment = "remove the link, as on a clean checkout" }, + [ + "vt", + "run", + "build", + ], + [ + "vtt", + "read-link", + "out/link", + ], +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md new file mode 100644 index 000000000..8cfba5c98 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/snapshots/symlink_in_output.md @@ -0,0 +1,31 @@ +# symlink_in_output + +The task links `out/link` to `out/target.txt`, a file in the fixture, like a build tool linking a dependency into its output. + +## `vt run build` + +``` +$ vtt symlink target.txt out/link +``` + +## `vtt rm out/link` + +remove the link, as on a clean checkout + +``` +``` + +## `vt run build` + +``` +$ vtt symlink target.txt out/link ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt read-link out/link` + +``` +target.txt +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json new file mode 100644 index 000000000..4ee14470c --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/symlink_in_output/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "build": { + "command": "vtt symlink target.txt out/link", + "cache": true + } + } +}