diff --git a/Cargo.toml b/Cargo.toml index e9ff0b2..1fe2960 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,14 @@ harness = true name = "workspace_integration_test" harness = true +[[test]] +name = "local_link_integration_test" +harness = true + +[[test]] +name = "workspace_version_integration_test" +harness = true + [[test]] name = "concurrent_execution_integration_test" harness = true diff --git a/tests/local_link_integration_test.rs b/tests/local_link_integration_test.rs new file mode 100644 index 0000000..75a1c97 --- /dev/null +++ b/tests/local_link_integration_test.rs @@ -0,0 +1,178 @@ +use serial_test::serial; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +fn write_test_local_dep_package(pkg_dir: &Path) { + fs::create_dir_all(pkg_dir).unwrap(); + let pkg_json = r#"{ + "name": "test_local_dep", + "version": "1.0.0", + "main": "index.js" +}"#; + let index_js = r#"module.exports = { + greet() { return "test_local_dep says hi"; } +};"#; + fs::write(pkg_dir.join("package.json"), pkg_json).unwrap(); + fs::write(pkg_dir.join("index.js"), index_js).unwrap(); +} + +fn write_app_with_dep(app_dir: &Path, dep_spec: &str) { + fs::create_dir_all(app_dir).unwrap(); + let package_json = format!( + r#"{{ + "name": "app-with-local-link", + "version": "1.0.0", + "main": "index.js", + "dependencies": {{ + "test_local_dep": "{}" + }} +}}"#, + dep_spec + ); + let index_js = r#"const test_local_dep = require('test_local_dep'); +console.log('Hello from app'); +console.log('test_local_dep:', test_local_dep.greet()); +process.exit(0);"#; + fs::write(app_dir.join("package.json"), package_json).unwrap(); + fs::write(app_dir.join("index.js"), index_js).unwrap(); +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + if dst.exists() { let _ = fs::remove_dir_all(dst); } + fs::create_dir_all(dst)?; + for entry in walkdir::WalkDir::new(src) { + let entry = entry.unwrap(); + let path = entry.path(); + let rel = path.strip_prefix(src).unwrap(); + let target = dst.join(rel); + if entry.file_type().is_dir() { + fs::create_dir_all(&target)?; + } else if entry.file_type().is_file() { + if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } + fs::copy(path, &target)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn make_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + use std::os::unix::fs as unix_fs; + if link.exists() { let _ = fs::remove_file(link); let _ = fs::remove_dir_all(link); } + if let Some(parent) = link.parent() { fs::create_dir_all(parent)?; } + unix_fs::symlink(target, link) +} + +#[cfg(windows)] +fn make_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + // Best-effort: try directory junction; fall back to copy if it fails + if link.exists() { let _ = fs::remove_file(link); let _ = fs::remove_dir_all(link); } + if let Some(parent) = link.parent() { fs::create_dir_all(parent)?; } + std::os::windows::fs::symlink_dir(target, link).or_else(|_| copy_dir_recursive(target, link)) +} + +fn prepare_node_modules_for_spec(app_dir: &Path, local_pkg_dir: &Path, mode: &str) { + let nm_test_local_dep = app_dir.join("node_modules").join("test_local_dep"); + match mode { + "copy" => { + copy_dir_recursive(local_pkg_dir, &nm_test_local_dep).unwrap(); + } + "symlink" => { + make_symlink(local_pkg_dir, &nm_test_local_dep).unwrap_or_else(|_| { + // Fallback to copy if symlink not permitted + copy_dir_recursive(local_pkg_dir, &nm_test_local_dep).unwrap(); + }); + } + _ => unreachable!(), + } +} + +fn bundle_app(app_dir: &Path, out_dir: &Path, name: &str) -> PathBuf { + // Build banderole if needed + let bundler = { + let target_dir = std::env::current_dir().unwrap().join("target"); + let path = if cfg!(windows) { + target_dir.join("debug/banderole.exe") + } else { + target_dir.join("debug/banderole") + }; + if !path.exists() { + let out = Command::new("cargo").args(["build"]).output().unwrap(); + assert!(out.status.success(), "Failed to build banderole: {}", String::from_utf8_lossy(&out.stderr)); + } + path + }; + + let output = Command::new(&bundler) + .args(["bundle", app_dir.to_str().unwrap(), "--no-compression", "--name", name]) + .current_dir(out_dir) + .output() + .unwrap(); + + assert!(output.status.success(), "Bundle failed: {}", String::from_utf8_lossy(&output.stderr)); + + let exe = out_dir.join(if cfg!(windows) { format!("{}.exe", name) } else { name.to_string() }); + if exe.exists() && exe.is_file() { exe } else { out_dir.join(if cfg!(windows) { format!("{}-bundle.exe", name) } else { format!("{}-bundle", name) }) } +} + +fn copy_exe_to_fresh_dir(exe: &Path) -> PathBuf { + let run_dir = TempDir::new().unwrap(); + let dst = run_dir + .path() + .join(exe.file_name().unwrap_or_default()); + fs::copy(exe, &dst).unwrap(); + // Keep dir alive by leaking TempDir, test process will clean temp space later + Box::leak(Box::new(run_dir)); + dst +} + +fn run_and_assert(exe: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(exe).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(exe, perms).unwrap(); + } + let out = Command::new(exe).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "Executable failed: {}", String::from_utf8_lossy(&out.stderr)); + assert!(stdout.contains("Hello from app")); + assert!(stdout.contains("test_local_dep: test_local_dep says hi")); +} + +fn build_and_verify_portable(dep_spec: &str, mode: &str, name: &str) { + // Each case uses its own workspace + let root_temp = TempDir::new().unwrap(); + let root = root_temp.path().to_path_buf(); + + let test_local_dep_dir = root.join("test_local_dep"); + write_test_local_dep_package(&test_local_dep_dir); + + let app = root.join("app"); + write_app_with_dep(&app, dep_spec); + prepare_node_modules_for_spec(&app, &test_local_dep_dir, mode); + + let exe = bundle_app(&app, &root, name); + let portable_exe = copy_exe_to_fresh_dir(&exe); + + // Remove original workspace entirely to ensure we don't accidentally resolve files from it + let _ = fs::remove_dir_all(&root); + + run_and_assert(&portable_exe); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_local_link_variants() { + // 1) file:../test_local_dep + build_and_verify_portable("file:../test_local_dep", "copy", "app-file"); + + // 2) link:../test_local_dep (prefer symlink; fallback to copy if not permitted) + build_and_verify_portable("link:../test_local_dep", "symlink", "app-link"); + + // 3) ../test_local_dep (bare relative) + build_and_verify_portable("../test_local_dep", "symlink", "app-bare"); +}