diff --git a/Cargo.lock b/Cargo.lock index f8b3232b17..672f84ee04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6244,6 +6244,7 @@ dependencies = [ "pnpm-workspace-state", "pnpr", "pnpr-fixtures", + "same-file", "serde_json", "ssri", "tar", diff --git a/pnpm/crates/cli/tests/suite/_utils/mod.rs b/pnpm/crates/cli/tests/suite/_utils/mod.rs index 742bd04623..c2d2c4b5bc 100644 --- a/pnpm/crates/cli/tests/suite/_utils/mod.rs +++ b/pnpm/crates/cli/tests/suite/_utils/mod.rs @@ -438,7 +438,6 @@ pub fn read_lockfile(path: &Path) -> Lockfile { /// lockfiles the upstream tests use to stage wanted/current divergence. /// `new_ref` takes any `snapshots:` dependency shape (`100.0.0`, /// `link:packages/foo`, ...). -#[cfg(unix)] pub fn repin_snapshot_dependency( lockfile_path: &Path, snapshot_key: &str, @@ -464,6 +463,30 @@ pub fn repin_snapshot_dependency( lockfile.save_to_path(lockfile_path).expect("write the rewritten lockfile"); } +/// Assert that `shim` is a bin a caller could actually invoke. +/// +/// What that takes differs per platform: Unix has the executable bit on +/// the extensionless shim, while Windows has no such bit and relies on the +/// `.cmd` / `.ps1` launchers written next to it. +pub fn assert_bin_linked(shim: &Path) { + assert!(shim.exists(), "the bin must be linked at {shim:?}"); + #[cfg(unix)] + assert!( + pnpm_testing_utils::fs::is_path_executable(shim), + "the bin shim at {shim:?} must be executable", + ); + #[cfg(windows)] + for extension in ["cmd", "ps1"] { + let launcher = shim.with_file_name(format!( + "{}.{extension}", + shim.file_name() + .expect("bin shim has a file name") + .to_string_lossy(), + )); + assert!(launcher.exists(), "the bin shim at {shim:?} needs its {extension} launcher"); + } +} + pub fn assert_success(output: &Output) { assert!( output.status.success(), @@ -489,7 +512,6 @@ pub fn ndjson_records(output: &Output) -> Vec { /// The `name: "pnpm" / level: "info"` log pnpm's headless installer /// emits when it is entered with an up-to-date lockfile. -#[cfg(unix)] #[must_use] pub fn has_up_to_date_log(records: &[Value]) -> bool { records diff --git a/pnpm/crates/cli/tests/suite/bin.rs b/pnpm/crates/cli/tests/suite/bin.rs index 7ddd44ce3e..8d07c2d2b0 100644 --- a/pnpm/crates/cli/tests/suite/bin.rs +++ b/pnpm/crates/cli/tests/suite/bin.rs @@ -246,13 +246,8 @@ fn bin_walks_past_an_ecosystem_manifest() { /// Differential parity from a workspace member, whose own `package.json` /// makes it the local prefix `bin` prints for. pacquet must match pnpm -/// byte-for-byte. Windows-skipped because it spawns the external `pnpm` -/// shim (see the `ignore` reason). +/// byte-for-byte. #[test] -#[cfg_attr( - target_os = "windows", - ignore = "spawns the external `pnpm` shim (`pnpm.cmd`); std::process::Command can't resolve it via PATHEXT" -)] fn bin_matches_pnpm_from_a_workspace_subdir() { let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); diff --git a/pnpm/crates/cli/tests/suite/bundled_dependencies.rs b/pnpm/crates/cli/tests/suite/bundled_dependencies.rs index a22b5ff9f4..7fce913d00 100644 --- a/pnpm/crates/cli/tests/suite/bundled_dependencies.rs +++ b/pnpm/crates/cli/tests/suite/bundled_dependencies.rs @@ -1,7 +1,7 @@ //! Ports of the TypeScript `bundledDependencies` install suite //! (`installing/deps-installer/test/install/bundledDependencies.ts`). -pub use _utils::{append_workspace_yaml_key, pacquet_in}; +pub use _utils::{append_workspace_yaml_key, assert_bin_linked, pacquet_in}; use assert_cmd::prelude::*; use command_extra::CommandExtra; @@ -217,29 +217,6 @@ fn bundled_dependencies_survive_a_lockfile_rewrite() { drop((root, npmrc_info)); // cleanup } -/// A linked bin means something different per platform: Unix has the -/// executable bit on the extensionless shim, while Windows has no such bit and -/// instead relies on the `.cmd` / `.ps1` launchers written next to it. Assert -/// whichever of the two actually makes the bin invocable on the host. -fn assert_bin_linked(shim: &Path) { - assert!(shim.exists(), "the bundled dependency's bin must be linked at {shim:?}"); - #[cfg(unix)] - assert!( - pnpm_testing_utils::fs::is_path_executable(shim), - "the bundled dependency's bin shim at {shim:?} must be executable", - ); - #[cfg(windows)] - for extension in ["cmd", "ps1"] { - let launcher = shim.with_file_name(format!( - "{}.{extension}", - shim.file_name() - .expect("bin shim has a file name") - .to_string_lossy(), - )); - assert!(launcher.exists(), "the bin shim at {shim:?} needs its {extension} launcher"); - } -} - fn read_wanted_lockfile(workspace: &Path) -> Lockfile { let text = fs::read_to_string(workspace.join(Lockfile::FILE_NAME)).expect("read pnpm-lock.yaml"); diff --git a/pnpm/crates/cli/tests/suite/hoist.rs b/pnpm/crates/cli/tests/suite/hoist.rs index a0f251fb1f..5e72823345 100644 --- a/pnpm/crates/cli/tests/suite/hoist.rs +++ b/pnpm/crates/cli/tests/suite/hoist.rs @@ -21,8 +21,6 @@ //! //! [#443]: https://github.com/pnpm/pacquet/pull/443 -#![cfg(unix)] // pnpm CLI: 'program not found' on Windows runners. - pub use _utils::*; use crate::_utils; @@ -923,9 +921,18 @@ fn should_add_extra_node_paths_to_command_shims() { let shim = fs::read_to_string(workspace.join("node_modules/.bin/hello-world-js-bin")) .expect("read the command shim"); + // Inside a relocatable root the shim names the entry relative to its + // own directory; on Windows `generate_sh_shim` writes the absolute + // path `@zkochan/cmd-shim` writes instead. + let hoisted_modules_entry = if cfg!(windows) { + "/node_modules/.pnpm/node_modules" + } else { + "$basedir_abs/../.pnpm/node_modules" + }; assert!( - shim.contains("$basedir_abs/../.pnpm/node_modules"), - "the shim must extend NODE_PATH with the hidden hoisted modules dir, relative to itself:\n{shim}", + shim.contains(hoisted_modules_entry), + "the shim must extend NODE_PATH with the hidden hoisted modules dir \ + ({hoisted_modules_entry}):\n{shim}", ); // The fresh install's own `packages:` rows must record `hasBin` — diff --git a/pnpm/crates/cli/tests/suite/ignore_workspace.rs b/pnpm/crates/cli/tests/suite/ignore_workspace.rs index 3d40bb7a6a..e76b2b2079 100644 --- a/pnpm/crates/cli/tests/suite/ignore_workspace.rs +++ b/pnpm/crates/cli/tests/suite/ignore_workspace.rs @@ -1,7 +1,5 @@ //! `--ignore-workspace` and `--workspace-packages`: the two flags that -//! change which workspace, if any, a command belongs to. The scripts run -//! through pacquet's `sh -c` executor, so the file is gated to Unix. -#![cfg(unix)] +//! change which workspace, if any, a command belongs to. use assert_cmd::prelude::*; use command_extra::CommandExtra; @@ -107,11 +105,13 @@ fn workspace_packages_overrides_the_manifest_patterns() { "--config.verify-deps-before-run=false", "-r", "exec", - "pwd", + "node", + "-e", + "console.log(process.cwd())", ])); let selected = stdout.lines().collect::>(); assert_eq!(selected.len(), 1, "only alfa should be selected: {stdout}"); - assert!(selected[0].ends_with("packages/alfa"), "wrong project selected: {stdout}"); + assert!(Path::new(selected[0]).ends_with("packages/alfa"), "wrong project selected: {stdout}"); drop(root); } diff --git a/pnpm/crates/cli/tests/suite/interrupt.rs b/pnpm/crates/cli/tests/suite/interrupt.rs index 331c0be59c..35545c2f3e 100644 --- a/pnpm/crates/cli/tests/suite/interrupt.rs +++ b/pnpm/crates/cli/tests/suite/interrupt.rs @@ -1,3 +1,9 @@ +//! Signal handling under `pnpm run`: what a `SIGINT` at the terminal +//! reaches, and what it leaves behind. +//! +//! Unix-only by subject, not by harness. The tests send POSIX signals to a +//! process group of their own; Windows delivers console control events +//! instead, which needs its own tests rather than a port of these. #![cfg(unix)] use command_extra::CommandExtra; diff --git a/pnpm/crates/cli/tests/suite/multiple_importers.rs b/pnpm/crates/cli/tests/suite/multiple_importers.rs index a1a714cbbf..77d92ebc54 100644 --- a/pnpm/crates/cli/tests/suite/multiple_importers.rs +++ b/pnpm/crates/cli/tests/suite/multiple_importers.rs @@ -10,13 +10,10 @@ //! equivalent is `pnpm --filter install` in a //! `pnpm-workspace.yaml` workspace. -#![cfg(unix)] // pnpm CLI: 'program not found' on Windows runners. - pub use _utils::*; use crate::_utils; -use pnpm_testing_utils::fs::is_path_executable; use serde_json::json; use std::fs; @@ -493,13 +490,13 @@ fn links_workspace_package_bin_into_dependent_project() { fixture.run(["install"]); let bin_path = main_project.join("node_modules/.bin/hello"); - assert!(is_path_executable(&bin_path), "expected an executable bin at {bin_path:?}"); + assert_bin_linked(&bin_path); fs::remove_dir_all(main_project.join("node_modules")).expect("remove main's node_modules"); fs::remove_dir_all(fixture.workspace.join("node_modules")).expect("remove root node_modules"); fixture.run(["install", "--frozen-lockfile"]); - assert!(is_path_executable(&bin_path), "the frozen reinstall must re-link the bin"); + assert_bin_linked(&bin_path); } /// TS: `custom virtual store directory in a workspace with shared @@ -695,8 +692,16 @@ fn link_bin_of_workspace_project_created_by_lifecycle_script() { assert!(consumer.join("created-by-prepare").exists()); } +/// A script that appends `label` and a newline to the shared order log. +/// +/// The newline comes from `String.fromCharCode` rather than a `\n` escape: +/// `sh -c` unescapes a backslash inside double quotes before Node sees it, +/// while `cmd /d /s /c` passes it through, so an escape would mean two +/// different things per platform. fn append_order_script(label: &str) -> String { - format!(r#"node -e "require('fs').appendFileSync('../../order.txt', '{label}\\n')""#) + format!( + r#"node -e "require('fs').appendFileSync('../../order.txt', '{label}' + String.fromCharCode(10))""#, + ) } /// TS: `dependencies of workspace projects are built during headless diff --git a/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs b/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs index 3979e73e12..24592de82f 100644 --- a/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs +++ b/pnpm/crates/cli/tests/suite/pipeline_cargo_cache.rs @@ -1,9 +1,11 @@ -#![cfg(unix)] +//! The pipeline's Cargo build cache: shared between worktrees, and +//! rebuilt after the cache directory is deleted. use assert_cmd::prelude::*; use command_extra::CommandExtra; use pnpm_testing_utils::command_env::CommandTestExt; use std::{ + env::consts::EXE_SUFFIX, fs::{self, File, FileTimes}, path::Path, process::Command, @@ -40,12 +42,23 @@ fn pnpm(root: &Path, cache: &Path, args: &[&str]) -> String { } fn run_binary(root: &Path) -> String { - let output = Command::new(root.join("target/debug/probe")).output().unwrap(); + let output = + Command::new(root.join(format!("target/debug/probe{EXE_SUFFIX}"))).output().unwrap(); assert!(output.status.success()); String::from_utf8(output.stdout).unwrap() } +/// Windows-skipped on a product gap this test is not the place to fix: the +/// second worktree recompiles instead of restoring the shared Cargo build +/// state. The run there places the cache under the 8.3 short form of the +/// temporary directory (`RUNNER~1`) while the worktree carries the long +/// one, which is the first thing to check. Tracked in +/// . #[test] +#[cfg_attr( + target_os = "windows", + ignore = "pnpm/pnpm#15105: the shared Cargo build state is not restored across worktrees" +)] fn cargo_state_is_shared_between_worktrees_and_survives_cache_deletion() { let temp = tempfile::tempdir().unwrap(); let root = dunce::canonicalize(temp.path()).unwrap(); diff --git a/pnpm/crates/cli/tests/suite/pipeline_watch.rs b/pnpm/crates/cli/tests/suite/pipeline_watch.rs index 5d8b22f732..dd63bf1fa9 100644 --- a/pnpm/crates/cli/tests/suite/pipeline_watch.rs +++ b/pnpm/crates/cli/tests/suite/pipeline_watch.rs @@ -1,8 +1,6 @@ //! Watch-agent integration tests: poll a git repository, build new //! revisions of a branch in a persistent checkout, and skip ticks with -//! nothing new. The build scripts run through pacquet's `sh -c` -//! executor, so the file is gated to Unix like the other run suites. -#![cfg(unix)] +//! nothing new. use assert_cmd::prelude::*; use command_extra::CommandExtra; @@ -42,7 +40,16 @@ fn agent_checkout(root: &Path) -> PathBuf { state_dir.join("checkout").join("demo") } +/// Windows-skipped on a product gap this test is not the place to fix: the +/// tick reports the revision built and passed, and the checkout holds no +/// task output afterwards. Tracked in +/// . The rest of the file runs +/// everywhere. #[test] +#[cfg_attr( + target_os = "windows", + ignore = "pnpm/pnpm#15105: the agent's build reports success but leaves no output in the checkout" +)] fn watch_agent_builds_new_revisions_and_skips_quiet_ticks() { let root = tempfile::Builder::new() .prefix("pacquet-test-") @@ -56,7 +63,7 @@ fn watch_agent_builds_new_revisions_and_skips_quiet_ticks() { ); fixture.write_file( "pkg/package.json", - r#"{ "name": "pkg", "version": "1.0.0", "scripts": { "build": "mkdir -p out && cp src/index.txt out/index.txt" } }"#, + r#"{ "name": "pkg", "version": "1.0.0", "scripts": { "build": "node -e \"const fs = require('fs'); fs.mkdirSync('out', { recursive: true }); fs.copyFileSync('src/index.txt', 'out/index.txt')\"" } }"#, ); fixture.write_file("pkg/src/index.txt", "v1"); // The lockfile the checkout's frozen install verifies against. @@ -104,7 +111,7 @@ fn watch_agent_builds_new_revisions_and_skips_quiet_ticks() { fixture.write_file( "pkg/package.json", - r#"{"name":"pkg","version":"1.0.0","scripts":{"build":"exit 1"}}"#, + r#"{"name":"pkg","version":"1.0.0","scripts":{"build":"node -e \"process.exit(1)\""}}"#, ); let failed = fixture.commit("failing build"); for _ in 0..2 { diff --git a/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs b/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs index 8fd65a449a..a126084940 100644 --- a/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs +++ b/pnpm/crates/cli/tests/suite/pnpm_compatibility.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] // running this on windows result in 'program not found' - pub use _utils::*; use crate::_utils; diff --git a/pnpm/crates/cli/tests/suite/pnpx_alias.rs b/pnpm/crates/cli/tests/suite/pnpx_alias.rs index 1f9a77d08c..f9a5d2abaf 100644 --- a/pnpm/crates/cli/tests/suite/pnpx_alias.rs +++ b/pnpm/crates/cli/tests/suite/pnpx_alias.rs @@ -1,9 +1,9 @@ //! Covers the `current_exe`-based `dlx` injection (`argv_with_alias_subcommand`) -//! that pnpm relies on for the Windows `pnpx`/`pnx` hardlinks, exercised on Unix -//! by copying the binary under the alias name — the same code runs everywhere. -#![cfg(unix)] +//! that pnpm relies on for the Windows `pnpx`/`pnx` hardlinks. The binary is +//! copied under the alias name rather than linked, which reaches the same code +//! on every platform. -use std::{fs, os::unix::fs::PermissionsExt, process::Command}; +use std::{env::consts::EXE_SUFFIX, fs, process::Command}; use tempfile::TempDir; @@ -12,9 +12,18 @@ fn launched_as_pnpx_injects_the_dlx_subcommand() { let pacquet = env!("CARGO_BIN_EXE_pnpm"); let dir = TempDir::new().expect("create temp dir"); - let pnpx = dir.path().join("pnpx"); + let pnpx = dir + .path() + .join(format!("pnpx{EXE_SUFFIX}")); fs::copy(pacquet, &pnpx).expect("copy the binary under the pnpx name"); - fs::set_permissions(&pnpx, fs::Permissions::from_mode(0o755)).expect("make pnpx executable"); + // Windows has no executable bit — the `.exe` suffix above is what + // makes the copy runnable there. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pnpx, fs::Permissions::from_mode(0o755)) + .expect("make pnpx executable"); + } let via_pnpx = Command::new(&pnpx) .arg("--help") diff --git a/pnpm/crates/cli/tests/suite/repeat_install.rs b/pnpm/crates/cli/tests/suite/repeat_install.rs index 2b7502cde9..79022164de 100644 --- a/pnpm/crates/cli/tests/suite/repeat_install.rs +++ b/pnpm/crates/cli/tests/suite/repeat_install.rs @@ -5,8 +5,6 @@ //! again, and asserts the second install converges without rebuilding //! what was still valid. -#![cfg(unix)] // pnpm CLI: 'program not found' on Windows runners. - pub use _utils::*; use crate::_utils; @@ -16,8 +14,9 @@ use command_extra::CommandExtra; use pnpm_testing_utils::{ bin::{AddMockedRegistry, CommandTempCwd}, fixtures::tarball_with_manifest, + fs::SameFileWitness, }; -use std::{fs, os::unix::fs::MetadataExt, path::Path}; +use std::{fs, path::Path}; /// `version` field of the `package.json` under `workspace/relative`. fn version_of(workspace: &Path, relative: &str) -> String { @@ -58,9 +57,15 @@ fn reinstalls_missing_packages_during_headless_install() { let dep_location = workspace.join("node_modules/.pnpm/is-positive@1.0.0/node_modules/is-positive"); + // Resolve the path while it still exists: Windows hands the tests a + // temporary directory under its 8.3 short name, and the reporter + // names the long one. + let resolved_dep_location = canonical_path(&dep_location); fs::remove_dir_all(&dep_location).expect("remove the virtual-store copy"); - fs::remove_file(workspace.join("node_modules/is-positive")) - .expect("remove the direct-dep symlink"); + // `remove_dirent` rather than `remove_file`: the direct dep is a + // junction on Windows, which `DeleteFileW` refuses. + pnpm_fs::remove_dirent(&workspace.join("node_modules/is-positive")) + .expect("remove the direct-dep link"); let second = pacquet_in(&workspace) .with_args(["install", "--frozen-lockfile", "--reporter=ndjson"]) @@ -71,9 +76,14 @@ fn reinstalls_missing_packages_during_headless_install() { second_events.contains("pnpm:_broken_node_modules"), "the missing dir must be reported: {second_events}", ); + // The event is NDJSON, so the path arrives with its separators + // escaped. Build the needle the way the reporter wrote it instead of + // matching the raw path, which no Windows event would contain. + let reported_dep_location = + serde_json::to_string(&resolved_dep_location).expect("serialize the missing path"); assert!( - second_events.contains(dep_location.to_str().expect("utf-8 path")), - "the event must carry the missing path", + second_events.contains(reported_dep_location.trim_matches('"')), + "the event must carry the missing path {reported_dep_location}: {second_events}", ); assert_eq!(version_of(&workspace, "node_modules/is-positive"), "1.0.0"); @@ -264,7 +274,7 @@ fn available_packages_used_when_node_modules_not_clean() { let foobarqar_manifest = workspace .join("node_modules/.pnpm/@pnpm.e2e+foobarqar@1.0.0/node_modules/@pnpm.e2e/foobarqar/package.json"); - let inode_before = fs::metadata(&foobarqar_manifest).expect("stat foobarqar").ino(); + let foobarqar_witness = SameFileWitness::take(&foobarqar_manifest, root.path()); fs::write( workspace.join("package.json"), @@ -296,9 +306,8 @@ fn available_packages_used_when_node_modules_not_clean() { .success(); assert!(workspace.join("node_modules/@pnpm.e2e/pkg-with-1-dep").exists()); - assert_eq!( - fs::metadata(&foobarqar_manifest).expect("stat foobarqar").ino(), - inode_before, + assert!( + foobarqar_witness.is_intact(), "the already-materialized package must be reused, not re-imported", ); let refetched: Vec = index_file_contents(&store_dir) @@ -802,7 +811,7 @@ fn repeat_hoisted_install_with_workspace_member_deps_is_up_to_date() { let AddMockedRegistry { mock_instance, .. } = npmrc_info; let hoisted_manifest = install_hoisted_workspace_member(pacquet, &workspace); - let inode_before = fs::metadata(&hoisted_manifest).expect("stat the hoisted dep").ino(); + let hoisted_witness = SameFileWitness::take(&hoisted_manifest, root.path()); let second = pacquet_in(&workspace) .with_arg("install") @@ -813,11 +822,7 @@ fn repeat_hoisted_install_with_workspace_member_deps_is_up_to_date() { second_output.contains("Already up to date"), "the repeat install must short-circuit: {second_output}", ); - assert_eq!( - fs::metadata(&hoisted_manifest).expect("stat the hoisted dep").ino(), - inode_before, - "the second install must re-import nothing", - ); + assert!(hoisted_witness.is_intact(), "the second install must re-import nothing"); drop((root, mock_instance)); } @@ -874,7 +879,7 @@ fn repeat_hoisted_install_with_unchanged_local_tarball_is_up_to_date() { .success(); let hoisted_manifest = workspace.join("node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep/package.json"); - let inode_before = fs::metadata(&hoisted_manifest).expect("stat the hoisted dep").ino(); + let hoisted_witness = SameFileWitness::take(&hoisted_manifest, root.path()); let second = pacquet_in(&workspace) .with_arg("install") @@ -885,11 +890,7 @@ fn repeat_hoisted_install_with_unchanged_local_tarball_is_up_to_date() { second_output.contains("Already up to date"), "the unchanged tarball must leave the fast path available: {second_output}", ); - assert_eq!( - fs::metadata(&hoisted_manifest).expect("stat the hoisted dep").ino(), - inode_before, - "the second install must re-import nothing", - ); + assert!(hoisted_witness.is_intact(), "the second install must re-import nothing"); drop((root, mock_instance)); } diff --git a/pnpm/crates/cli/tests/suite/root.rs b/pnpm/crates/cli/tests/suite/root.rs index 051d1caa2f..299e329e99 100644 --- a/pnpm/crates/cli/tests/suite/root.rs +++ b/pnpm/crates/cli/tests/suite/root.rs @@ -153,17 +153,7 @@ fn root_global_writes_warnings_to_stderr_so_stdout_stays_a_clean_path() { /// Differential parity: from a workspace subdirectory pnpm's `root` prints the /// cwd's `node_modules` (its `config.dir` is the cwd, not the workspace root). /// pacquet must print byte-identical output. -/// -/// Skipped on Windows, where pnpm is installed as a `pnpm.cmd` shim and -/// `std::process::Command` does not honor `PATHEXT`, so `Command::new("pnpm")` -/// fails with "program not found" (the same reason `pnpm_compatibility.rs` and -/// `hoist.rs` gate their pnpm-spawning tests). The three tests above spawn only -/// `pacquet`, so they keep running on Windows. #[test] -#[cfg_attr( - target_os = "windows", - ignore = "spawns the external `pnpm` shim (`pnpm.cmd`); std::process::Command can't resolve it via PATHEXT" -)] fn root_matches_pnpm_from_a_workspace_subdir() { let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); diff --git a/pnpm/crates/cli/tests/suite/side_effects_cache.rs b/pnpm/crates/cli/tests/suite/side_effects_cache.rs index 6bf4d4e414..e39e97e788 100644 --- a/pnpm/crates/cli/tests/suite/side_effects_cache.rs +++ b/pnpm/crates/cli/tests/suite/side_effects_cache.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] - use assert_cmd::prelude::*; use command_extra::CommandExtra; use pnpm_testing_utils::{ diff --git a/pnpm/crates/testing-utils/Cargo.toml b/pnpm/crates/testing-utils/Cargo.toml index 88487aa4c7..cdfcbf1ae9 100644 --- a/pnpm/crates/testing-utils/Cargo.toml +++ b/pnpm/crates/testing-utils/Cargo.toml @@ -17,6 +17,7 @@ dunce = { workspace = true } flate2 = { workspace = true } pipe-trait = { workspace = true } pnpr = { workspace = true } +same-file = { workspace = true } serde_json = { workspace = true } ssri = { workspace = true } tar = { workspace = true } diff --git a/pnpm/crates/testing-utils/src/fs.rs b/pnpm/crates/testing-utils/src/fs.rs index 03b434d658..560c003db6 100644 --- a/pnpm/crates/testing-utils/src/fs.rs +++ b/pnpm/crates/testing-utils/src/fs.rs @@ -2,9 +2,10 @@ use pipe_trait::Pipe; use pnpm_workspace_state::load_workspace_state; use std::{ fs, io, - path::Path, + path::{Path, PathBuf}, time::{Duration, SystemTime}, }; +use tempfile::TempDir; use walkdir::WalkDir; #[must_use] @@ -73,6 +74,41 @@ pub fn is_path_executable(path: &Path) -> bool { mode & 0b001_001_001 != 0 } +/// A record of which on-disk file a path named at one point in time, so a +/// later check can tell a file an install reused from one it replaced. +/// +/// The record is a hard link, taken in a directory outside the tree under +/// test, and [`Self::is_intact`] compares the two paths with `same_file`. +/// Unix could keep the inode number instead, but `std` exposes the Windows +/// equivalent only behind an unstable feature. +pub struct SameFileWitness { + path: PathBuf, + /// Owns the directory the link lives in, so the link goes away with + /// the witness. + dir: TempDir, +} + +impl SameFileWitness { + /// Link `path` from a directory of its own under `witness_dir`, which + /// has to be on the same filesystem as `path` and outside whatever the + /// step under test rewrites. + #[must_use] + pub fn take(path: &Path, witness_dir: &Path) -> Self { + let dir = TempDir::new_in(witness_dir).expect("create the witness directory"); + let link = dir.path().join("link"); + fs::hard_link(path, &link) + .unwrap_or_else(|error| panic!("link {path:?} from {link:?}: {error}")); + SameFileWitness { path: path.to_path_buf(), dir } + } + + /// Whether the path still names the file it named when the witness was + /// taken. + #[must_use] + pub fn is_intact(&self) -> bool { + same_file::is_same_file(&self.path, self.dir.path().join("link")).unwrap_or(false) + } +} + /// The gap that separates two mtimes on every filesystem the tests run on. /// /// A full second, because the coarsest supported filesystems (HFS+, ext4