From 2d98d3fa3fd2f795ee36089b45b575b648217f3a Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:41:06 +0400 Subject: [PATCH] feat: support pnpm workspaces --- README.md | 4 +- src/bundler.rs | 523 ++++++++++++++++++--- tests/common/mod.rs | 675 ++++++++++++++++++++++++++++ tests/workspace_integration_test.rs | 439 ++++++++++++++++++ 4 files changed, 1587 insertions(+), 54 deletions(-) create mode 100644 tests/common/mod.rs create mode 100644 tests/workspace_integration_test.rs diff --git a/README.md b/README.md index 142fe20..21b8874 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Create cross-platform single-executables for Node.js projects. Banderole bundles your Node.js app, all dependencies, and a portable Node binary into a single executable. On first launch, it unpacks to a cache directory for fast subsequent executions. -Unlike [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) or [pkg](https://github.com/yao-pkg/pkg), banderole handles complex projects with dynamic imports and non-JavaScript files without requiring patches, but since it includes all dependencies by default, it has significantly large filesize. +Unlike [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) or [pkg](https://github.com/yao-pkg/pkg), banderole handles complex projects with dynamic imports and non-JavaScript files without requiring patches, but since it includes all dependencies by default, it has significantly larger filesize. ## Installation @@ -33,7 +33,7 @@ banderole bundle /path/to/project --output /path/to/my-app --name my-app - [x] Support Linux, MacOS, and Windows for both x64 and arm64 architectures. - [x] Support custom node.js version based on project's `.nvmrc` and `.node-version` - [x] Support TypeScript projects with automatic detection of compiled output directories -- [ ] Support workspaces (currently you need to install dependencies directly) +- [x] Support workspaces (only pnpm workspaces tested) - [ ] Only the executable has permissions to read and execute bundled files ## License diff --git a/src/bundler.rs b/src/bundler.rs index a68689a..603f49b 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -163,33 +163,59 @@ where if project_node_modules.exists() { let package_manager = detect_package_manager(&project_node_modules, project_path); - match package_manager { - PackageManager::Pnpm => { - // For pnpm, we need to bundle both node_modules and .pnpm if it exists - bundle_pnpm_dependencies(zip, project_path, opts)?; - return Ok(DependenciesResult { - dependencies_found: true, - source_description: "pnpm dependencies (node_modules + .pnpm)".to_string(), - warnings, - }); + // Check if this is a pnpm workspace (symlinks to parent .pnpm) + let is_pnpm_workspace = if package_manager == PackageManager::Pnpm { + // Check if the pnpm structure points to a parent directory + if let Ok(entries) = fs::read_dir(&project_node_modules) { + entries.flatten().any(|entry| { + if entry.file_type().ok().map_or(false, |ft| ft.is_symlink()) { + if let Ok(target) = fs::read_link(entry.path()) { + let target_str = target.to_string_lossy(); + target_str.contains("/.pnpm/") && target_str.starts_with("../") + } else { + false + } + } else { + false + } + }) + } else { + false } - PackageManager::Yarn => { - // For yarn, bundle node_modules with improved symlink resolution - bundle_node_modules_with_symlink_resolution(zip, &project_node_modules, opts)?; - return Ok(DependenciesResult { - dependencies_found: true, - source_description: "yarn dependencies (node_modules)".to_string(), - warnings, - }); - } - PackageManager::Npm | PackageManager::Unknown => { - // For npm or unknown, use standard bundling with improved symlink handling - bundle_node_modules_with_symlink_resolution(zip, &project_node_modules, opts)?; - return Ok(DependenciesResult { - dependencies_found: true, - source_description: "npm dependencies (node_modules)".to_string(), - warnings, - }); + } else { + false + }; + + // If it's a pnpm workspace, skip local bundling and go to workspace detection + if !is_pnpm_workspace { + match package_manager { + PackageManager::Pnpm => { + // For local pnpm, bundle both node_modules and .pnpm if it exists + bundle_pnpm_dependencies(zip, project_path, opts)?; + return Ok(DependenciesResult { + dependencies_found: true, + source_description: "pnpm dependencies (node_modules + .pnpm)".to_string(), + warnings, + }); + } + PackageManager::Yarn => { + // For yarn, bundle node_modules with comprehensive dependency resolution + bundle_node_modules_comprehensive(zip, &project_node_modules, project_path, opts)?; + return Ok(DependenciesResult { + dependencies_found: true, + source_description: "yarn dependencies (node_modules)".to_string(), + warnings, + }); + } + PackageManager::Npm | PackageManager::Unknown => { + // For npm or unknown, use comprehensive bundling + bundle_node_modules_comprehensive(zip, &project_node_modules, project_path, opts)?; + return Ok(DependenciesResult { + dependencies_found: true, + source_description: "npm dependencies (node_modules)".to_string(), + warnings, + }); + } } } } @@ -202,31 +228,44 @@ where if parent_node_modules.exists() && parent_package_json.exists() { // Check if this is a workspace root + let mut is_workspace = false; + + // Check package.json for workspace configuration if let Ok(content) = fs::read_to_string(&parent_package_json) { if let Ok(pkg_value) = serde_json::from_str::(&content) { - if pkg_value["workspaces"].is_array() || pkg_value["workspaces"]["packages"].is_array() { - warnings.push(format!("Found workspace dependencies in parent directory: {}", parent_path.display())); - - let package_manager = detect_package_manager(&parent_node_modules, parent_path); - - match package_manager { - PackageManager::Pnpm => { - bundle_pnpm_dependencies(zip, parent_path, opts)?; - return Ok(DependenciesResult { - dependencies_found: true, - source_description: format!("workspace pnpm dependencies from {}", parent_path.display()), - warnings, - }); - } - _ => { - bundle_node_modules_with_symlink_resolution(zip, &parent_node_modules, opts)?; - return Ok(DependenciesResult { - dependencies_found: true, - source_description: format!("workspace dependencies from {}", parent_path.display()), - warnings, - }); - } - } + is_workspace = pkg_value["workspaces"].is_array() + || pkg_value["workspaces"]["packages"].is_array() + || pkg_value["workspaces"].is_object(); + } + } + + // Check for pnpm-workspace.yaml + let pnpm_workspace_yaml = parent_path.join("pnpm-workspace.yaml"); + if !is_workspace && pnpm_workspace_yaml.exists() { + is_workspace = true; + } + + if is_workspace { + warnings.push(format!("Found workspace dependencies in parent directory: {}", parent_path.display())); + + let package_manager = detect_package_manager(&parent_node_modules, parent_path); + + match package_manager { + PackageManager::Pnpm => { + bundle_pnpm_workspace_dependencies(zip, parent_path, project_path, opts)?; + return Ok(DependenciesResult { + dependencies_found: true, + source_description: format!("workspace pnpm dependencies from {}", parent_path.display()), + warnings, + }); + } + _ => { + bundle_workspace_dependencies(zip, &parent_node_modules, parent_path, project_path, opts)?; + return Ok(DependenciesResult { + dependencies_found: true, + source_description: format!("workspace dependencies from {}", parent_path.display()), + warnings, + }); } } } @@ -262,6 +301,22 @@ fn detect_package_manager(node_modules_path: &Path, project_path: &Path) -> Pack return PackageManager::Pnpm; } + // Check if this is a pnpm workspace (symlinks pointing to parent .pnpm) + if node_modules_path.exists() { + if let Ok(entries) = fs::read_dir(node_modules_path) { + for entry in entries.flatten() { + if entry.file_type().ok().map_or(false, |ft| ft.is_symlink()) { + if let Ok(target) = fs::read_link(entry.path()) { + let target_str = target.to_string_lossy(); + if target_str.contains("/.pnpm/") { + return PackageManager::Pnpm; + } + } + } + } + } + } + // Check for lockfiles in the project directory if project_path.join("pnpm-lock.yaml").exists() { return PackageManager::Pnpm; @@ -566,16 +621,273 @@ where Ok(()) } -/// Bundle node_modules with improved symlink resolution -fn bundle_node_modules_with_symlink_resolution( + + +/// Bundle node_modules with comprehensive dependency resolution +fn bundle_node_modules_comprehensive( zip: &mut ZipWriter, node_modules_path: &Path, + project_path: &Path, opts: zip::write::FileOptions<'static, ()>, ) -> Result<()> where W: Write + Read + std::io::Seek, { - add_dir_to_zip(zip, node_modules_path, Path::new("app/node_modules"), opts) + let mut packages_to_bundle = std::collections::HashSet::new(); + + // Start with direct dependencies from package.json + let package_json_path = project_path.join("package.json"); + if let Ok(package_json_content) = fs::read_to_string(&package_json_path) { + if let Ok(package_json) = serde_json::from_str::(&package_json_content) { + if let Some(deps) = package_json["dependencies"].as_object() { + for dep_name in deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + // Also include peerDependencies and optionalDependencies + if let Some(peer_deps) = package_json["peerDependencies"].as_object() { + for dep_name in peer_deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + if let Some(optional_deps) = package_json["optionalDependencies"].as_object() { + for dep_name in optional_deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + } + } + + // Check if this is a pnpm setup + let pnpm_dir = node_modules_path.join(".pnpm"); + if pnpm_dir.exists() { + // Use pnpm-specific resolution + let mut resolved_packages = std::collections::HashSet::new(); + for package_name in &packages_to_bundle { + resolve_package_dependencies( + node_modules_path, + &pnpm_dir, + package_name, + &mut resolved_packages, + 0, + )?; + } + + println!("Bundling {} packages (resolved dependencies) for pnpm node_modules", resolved_packages.len()); + + // Ensure app/node_modules directory exists + zip.add_directory("app/node_modules/", opts)?; + + // Copy each resolved package using pnpm logic + for package_name in &resolved_packages { + if let Err(e) = copy_pnpm_package_comprehensive(zip, node_modules_path, &pnpm_dir, package_name, opts) { + println!("Warning: Failed to copy package {}: {}", package_name, e); + } + } + } else { + // Use regular workspace resolution for non-pnpm setups + let mut resolved_packages = std::collections::HashSet::new(); + for package_name in &packages_to_bundle { + resolve_workspace_dependencies( + node_modules_path, + package_name, + &mut resolved_packages, + 0, + )?; + } + + println!("Bundling {} packages (resolved dependencies) for regular node_modules", resolved_packages.len()); + + // Ensure app/node_modules directory exists + zip.add_directory("app/node_modules/", opts)?; + + // Copy each resolved package using workspace logic + for package_name in &resolved_packages { + if let Err(e) = copy_workspace_package(zip, node_modules_path, package_name, opts) { + println!("Warning: Failed to copy package {}: {}", package_name, e); + } + } + } + + // Copy .bin directory if it exists + let bin_dir = node_modules_path.join(".bin"); + if bin_dir.exists() { + add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?; + } + + // Copy important metadata files + let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"]; + for file_name in important_files { + let file_path = node_modules_path.join(file_name); + if file_path.exists() { + let dest_path = Path::new("app/node_modules").join(file_name); + zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?; + let data = fs::read(&file_path)?; + zip.write_all(&data)?; + } + } + + Ok(()) +} + +/// Bundle workspace dependencies (node_modules from parent) +fn bundle_workspace_dependencies( + zip: &mut ZipWriter, + node_modules_path: &Path, + _parent_path: &Path, + project_path: &Path, + opts: zip::write::FileOptions<'static, ()>, +) -> Result<()> +where + W: Write + Read + std::io::Seek, +{ + let mut packages_to_bundle = std::collections::HashSet::new(); + + // Read dependencies from the ACTUAL PROJECT being bundled, not the workspace root + let package_json_path = project_path.join("package.json"); + if let Ok(package_json_content) = fs::read_to_string(&package_json_path) { + if let Ok(package_json) = serde_json::from_str::(&package_json_content) { + if let Some(deps) = package_json["dependencies"].as_object() { + for dep_name in deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + // Also include peerDependencies and optionalDependencies + if let Some(peer_deps) = package_json["peerDependencies"].as_object() { + for dep_name in peer_deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + if let Some(optional_deps) = package_json["optionalDependencies"].as_object() { + for dep_name in optional_deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + } + } + + // Recursively resolve dependencies for each package using workspace-specific logic + let mut resolved_packages = std::collections::HashSet::new(); + for package_name in &packages_to_bundle { + resolve_workspace_dependencies( + node_modules_path, + package_name, + &mut resolved_packages, + 0, // depth + )?; + } + + println!("Bundling {} packages (resolved dependencies) for workspace node_modules", resolved_packages.len()); + + // Ensure app/node_modules directory exists + zip.add_directory("app/node_modules/", opts)?; + + // Copy each resolved package using workspace-specific copying + for package_name in &resolved_packages { + if let Err(e) = copy_workspace_package(zip, node_modules_path, package_name, opts) { + println!("Warning: Failed to copy package {}: {}", package_name, e); + } + } + + // Copy .bin directory if it exists + let bin_dir = node_modules_path.join(".bin"); + if bin_dir.exists() { + add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?; + } + + // Copy important workspace metadata files if they exist + let important_files = [".modules.yaml"]; + for file_name in important_files { + let file_path = node_modules_path.join(file_name); + if file_path.exists() { + let dest_path = Path::new("app/node_modules").join(file_name); + zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?; + let data = fs::read(&file_path)?; + zip.write_all(&data)?; + } + } + + Ok(()) +} + +/// Bundle pnpm workspace dependencies (node_modules from parent) +fn bundle_pnpm_workspace_dependencies( + zip: &mut ZipWriter, + parent_path: &Path, + project_path: &Path, + opts: zip::write::FileOptions<'static, ()>, +) -> Result<()> +where + W: Write + Read + std::io::Seek, +{ + let mut packages_to_bundle = std::collections::HashSet::new(); + + // Read dependencies from the ACTUAL PROJECT being bundled, not the workspace root + let package_json_path = project_path.join("package.json"); + if let Ok(package_json_content) = fs::read_to_string(&package_json_path) { + if let Ok(package_json) = serde_json::from_str::(&package_json_content) { + if let Some(deps) = package_json["dependencies"].as_object() { + for dep_name in deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + // Also include peerDependencies and optionalDependencies + if let Some(peer_deps) = package_json["peerDependencies"].as_object() { + for dep_name in peer_deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + if let Some(optional_deps) = package_json["optionalDependencies"].as_object() { + for dep_name in optional_deps.keys() { + packages_to_bundle.insert(dep_name.clone()); + } + } + } + } + + // Recursively resolve dependencies for each package using pnpm-specific logic + let mut resolved_packages = std::collections::HashSet::new(); + for package_name in &packages_to_bundle { + resolve_package_dependencies( + &parent_path.join("node_modules"), + &parent_path.join("node_modules").join(".pnpm"), + package_name, + &mut resolved_packages, + 0, // depth + )?; + } + + println!("Bundling {} packages (resolved dependencies) for workspace pnpm node_modules", resolved_packages.len()); + + // Ensure app/node_modules directory exists + zip.add_directory("app/node_modules/", opts)?; + + // Copy each resolved package using pnpm-specific copying + for package_name in &resolved_packages { + if let Err(e) = copy_pnpm_package_comprehensive(zip, &parent_path.join("node_modules"), &parent_path.join("node_modules").join(".pnpm"), package_name, opts) { + println!("Warning: Failed to copy package {}: {}", package_name, e); + } + } + + // Copy .bin directory if it exists + let bin_dir = parent_path.join("node_modules").join(".bin"); + if bin_dir.exists() { + add_dir_to_zip_no_follow(zip, &bin_dir, Path::new("app/node_modules/.bin"), opts)?; + } + + // Copy important pnpm metadata files + let important_files = [".modules.yaml", ".pnpm-workspace-state-v1.json"]; + for file_name in important_files { + let file_path = parent_path.join("node_modules").join(file_name); + if file_path.exists() { + let dest_path = Path::new("app/node_modules").join(file_name); + zip.start_file(dest_path.to_string_lossy().as_ref(), opts)?; + let data = fs::read(&file_path)?; + zip.write_all(&data)?; + } + } + + Ok(()) } /// Very lightweight Node version detection. @@ -1168,4 +1480,111 @@ where Ok(()) } +/// Copy a package from workspace node_modules (for regular npm/yarn workspaces) +fn copy_workspace_package( + zip: &mut ZipWriter, + node_modules_path: &Path, + package_name: &str, + opts: zip::write::FileOptions<'static, ()>, +) -> Result<()> +where + W: Write + Read + std::io::Seek, +{ + let dest_path = Path::new("app/node_modules").join(package_name); + let package_path = node_modules_path.join(package_name); + + if package_path.exists() { + let target_path = if package_path.is_symlink() { + // Follow the symlink + let target = fs::read_link(&package_path)?; + if target.is_absolute() { + target + } else { + package_path.parent().unwrap().join(target).canonicalize()? + } + } else { + package_path + }; + + if target_path.exists() { + add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts)?; + return Ok(()); + } + } + + anyhow::bail!("Package {} not found in workspace node_modules", package_name) +} + +/// Resolve dependencies for regular workspaces (non-pnpm) +fn resolve_workspace_dependencies( + node_modules_path: &Path, + package_name: &str, + resolved: &mut std::collections::HashSet, + depth: usize, +) -> Result<()> { + // Avoid infinite recursion + if depth > 20 { + return Ok(()); + } + + // If already resolved, skip + if resolved.contains(package_name) { + return Ok(()); + } + + resolved.insert(package_name.to_string()); + + // Try to find the package and read its package.json + let package_path = node_modules_path.join(package_name); + let package_json_path = if package_path.is_symlink() { + let target = fs::read_link(&package_path)?; + let target_path = if target.is_absolute() { + target + } else { + package_path.parent().unwrap().join(target).canonicalize()? + }; + target_path.join("package.json") + } else { + package_path.join("package.json") + }; + + if !package_json_path.exists() { + return Ok(()); // Skip packages we can't find + } + + let package_json_content = fs::read_to_string(&package_json_path) + .context("Failed to read package.json")?; + + if let Ok(package_json) = serde_json::from_str::(&package_json_content) { + // Add production dependencies + if let Some(deps) = package_json["dependencies"].as_object() { + for dep_name in deps.keys() { + resolve_workspace_dependencies(node_modules_path, dep_name, resolved, depth + 1)?; + } + } + + // Also include peerDependencies that are actually installed + if let Some(peer_deps) = package_json["peerDependencies"].as_object() { + for dep_name in peer_deps.keys() { + let dep_path = node_modules_path.join(dep_name); + if dep_path.exists() { + resolve_workspace_dependencies(node_modules_path, dep_name, resolved, depth + 1)?; + } + } + } + + // Also include optionalDependencies that are actually installed + if let Some(optional_deps) = package_json["optionalDependencies"].as_object() { + for dep_name in optional_deps.keys() { + let dep_path = node_modules_path.join(dep_name); + if dep_path.exists() { + resolve_workspace_dependencies(node_modules_path, dep_name, resolved, depth + 1)?; + } + } + } + } + + Ok(()) +} + diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..b106877 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,675 @@ +use std::process::Command; +use std::time::Duration; +use tempfile::TempDir; +use std::fs; +use std::path::{Path, PathBuf}; +use anyhow::Result; + +/// Represents different project types for testing +#[derive(Debug, Clone)] +pub enum ProjectType { + Simple, + TypeScript { out_dir: String }, + Workspace, + PnpmWorkspace, +} + +/// Represents a test project configuration +#[derive(Debug, Clone)] +pub struct TestProject { + pub name: String, + pub project_type: ProjectType, + pub dependencies: Vec<(String, String)>, // (name, version) + pub dev_dependencies: Vec<(String, String)>, + pub has_nvmrc: Option, + pub has_node_version: Option, +} + +impl Default for TestProject { + fn default() -> Self { + Self { + name: "test-project".to_string(), + project_type: ProjectType::Simple, + dependencies: vec![], + dev_dependencies: vec![], + has_nvmrc: None, + has_node_version: None, + } + } +} + +impl TestProject { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + ..Default::default() + } + } + + pub fn with_dependency(mut self, name: &str, version: &str) -> Self { + self.dependencies.push((name.to_string(), version.to_string())); + self + } + + pub fn with_dev_dependency(mut self, name: &str, version: &str) -> Self { + self.dev_dependencies.push((name.to_string(), version.to_string())); + self + } + + pub fn with_nvmrc(mut self, version: &str) -> Self { + self.has_nvmrc = Some(version.to_string()); + self + } + + pub fn with_node_version(mut self, version: &str) -> Self { + self.has_node_version = Some(version.to_string()); + self + } + + pub fn typescript(mut self, out_dir: &str) -> Self { + self.project_type = ProjectType::TypeScript { out_dir: out_dir.to_string() }; + self + } + + pub fn workspace(mut self) -> Self { + self.project_type = ProjectType::Workspace; + self + } + + pub fn pnpm_workspace(mut self) -> Self { + self.project_type = ProjectType::PnpmWorkspace; + self + } +} + +/// Test project manager for creating and managing test projects +pub struct TestProjectManager { + temp_dir: TempDir, + project_path: PathBuf, + workspace_root: Option, +} + +impl TestProjectManager { + /// Create a new test project in a temporary directory + pub fn create(config: TestProject) -> Result { + let temp_dir = TempDir::new()?; + let mut manager = Self { + temp_dir, + project_path: PathBuf::new(), + workspace_root: None, + }; + + match config.project_type { + ProjectType::Simple => { + manager.project_path = manager.temp_dir.path().join(&config.name); + manager.create_simple_project(&config)?; + } + ProjectType::TypeScript { ref out_dir } => { + manager.project_path = manager.temp_dir.path().join(&config.name); + manager.create_typescript_project(&config, out_dir)?; + } + ProjectType::Workspace => { + manager.workspace_root = Some(manager.temp_dir.path().join("workspace")); + manager.project_path = manager.workspace_root.as_ref().unwrap().join(&config.name); + manager.create_workspace_project(&config)?; + } + ProjectType::PnpmWorkspace => { + manager.workspace_root = Some(manager.temp_dir.path().join("workspace")); + manager.project_path = manager.workspace_root.as_ref().unwrap().join(&config.name); + manager.create_pnpm_workspace_project(&config)?; + } + } + + Ok(manager) + } + + /// Get the path to the project being tested + pub fn project_path(&self) -> &Path { + &self.project_path + } + + /// Get the path to the workspace root (if this is a workspace project) + pub fn workspace_root(&self) -> Option<&Path> { + self.workspace_root.as_deref() + } + + /// Get the temporary directory path + pub fn temp_dir(&self) -> &Path { + self.temp_dir.path() + } + + /// Install dependencies using npm + pub fn install_dependencies(&self) -> Result<()> { + let npm_install = Command::new("npm") + .args(["install"]) + .current_dir(&self.project_path) + .output()?; + + if !npm_install.status.success() { + anyhow::bail!( + "npm install failed: {}", + String::from_utf8_lossy(&npm_install.stderr) + ); + } + + Ok(()) + } + + /// Install dependencies using pnpm + pub fn install_pnpm_dependencies(&self) -> Result<()> { + // First try pnpm + let pnpm_install = Command::new("pnpm") + .args(["install"]) + .current_dir(self.workspace_root.as_ref().unwrap_or(&self.project_path)) + .output(); + + match pnpm_install { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + anyhow::bail!( + "pnpm install failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Err(_) => { + // Fallback to npm if pnpm is not available + println!("pnpm not found, falling back to npm"); + self.install_dependencies() + } + } + } + + /// Install dependencies in workspace root + pub fn install_workspace_dependencies(&self) -> Result<()> { + if let Some(workspace_root) = &self.workspace_root { + let npm_install = Command::new("npm") + .args(["install"]) + .current_dir(workspace_root) + .output()?; + + if !npm_install.status.success() { + anyhow::bail!( + "npm install failed in workspace root: {}", + String::from_utf8_lossy(&npm_install.stderr) + ); + } + } + Ok(()) + } + + fn create_simple_project(&self, config: &TestProject) -> Result<()> { + fs::create_dir_all(&self.project_path)?; + + let package_json = self.generate_package_json(config)?; + fs::write(self.project_path.join("package.json"), package_json)?; + + let index_js = r#"console.log("Hello from test project!"); +console.log("Node version:", process.version); +console.log("Platform:", process.platform); +console.log("Architecture:", process.arch); + +// Test environment variables +console.log("Test env var:", process.env.TEST_VAR || 'not set'); + +// Test process arguments +console.log("Process args:", process.argv.slice(2)); + +// Test dependencies if any +try { + const deps = require('./package.json').dependencies || {}; + console.log("Dependencies:", Object.keys(deps)); + + // Test specific commonly used dependencies + if (deps['adm-zip']) { + const AdmZip = require('adm-zip'); + console.log("Successfully loaded adm-zip:", typeof AdmZip); + + // Test basic functionality + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.from("test content")); + const entries = zip.getEntries(); + console.log("Zip entries count:", entries.length); + console.log("DEPENDENCY_TEST_PASSED"); + } +} catch (e) { + console.error("Dependency test failed:", e.message); + console.log("DEPENDENCY_TEST_FAILED"); +} + +console.log("All tests completed!"); +process.exit(0);"#; + + fs::write(self.project_path.join("index.js"), index_js)?; + + // Add Node version files if specified + if let Some(ref version) = config.has_nvmrc { + fs::write(self.project_path.join(".nvmrc"), version)?; + } + if let Some(ref version) = config.has_node_version { + fs::write(self.project_path.join(".node-version"), version)?; + } + + Ok(()) + } + + fn create_typescript_project(&self, config: &TestProject, out_dir: &str) -> Result<()> { + fs::create_dir_all(&self.project_path)?; + fs::create_dir_all(self.project_path.join(out_dir))?; + + let mut package_json = self.generate_package_json(config)?; + // Update main to point to compiled output + let mut package_obj: serde_json::Value = serde_json::from_str(&package_json)?; + package_obj["main"] = serde_json::Value::String(format!("{}/index.js", out_dir)); + package_json = serde_json::to_string_pretty(&package_obj)?; + + fs::write(self.project_path.join("package.json"), package_json)?; + + let tsconfig_json = format!(r#"{{ + "compilerOptions": {{ + "target": "ES2020", + "module": "commonjs", + "outDir": "./{out_dir}", + "rootDir": "./src", + "strict": true + }} +}}"#); + + fs::write(self.project_path.join("tsconfig.json"), tsconfig_json)?; + + // Create source TypeScript file + fs::create_dir_all(self.project_path.join("src"))?; + let src_index_ts = r#"console.log("Hello from TypeScript project!"); +console.log("Node version:", process.version); +console.log("This should come from the compiled output directory"); +try { + const marker = require('./marker.js'); + console.log("Marker file found:", marker.source); +} catch (e) { + console.log("Marker file not found"); +}"#; + + fs::write(self.project_path.join("src/index.ts"), src_index_ts)?; + + // Create compiled output + let compiled_index_js = r#"console.log("Hello from TypeScript project!"); +console.log("Node version:", process.version); +console.log("This should come from the compiled output directory"); +try { + const marker = require('./marker.js'); + console.log("Marker file found:", marker.source); +} catch (e) { + console.log("Marker file not found"); +}"#; + + fs::write(self.project_path.join(out_dir).join("index.js"), compiled_index_js)?; + + // Create a marker file to verify correct source directory is used + let marker_js = format!(r#"module.exports = {{ source: "{}" }};"#, out_dir); + fs::write(self.project_path.join(out_dir).join("marker.js"), marker_js)?; + + Ok(()) + } + + fn create_workspace_project(&self, config: &TestProject) -> Result<()> { + let workspace_root = self.workspace_root.as_ref().unwrap(); + fs::create_dir_all(workspace_root)?; + fs::create_dir_all(&self.project_path)?; + + // Create workspace root package.json + let workspace_package_json = format!(r#"{{ + "name": "test-workspace", + "version": "1.0.0", + "private": true, + "workspaces": [ + "{}" + ], + "dependencies": {{ +{} + }} +}}"#, config.name, self.format_dependencies(&config.dependencies)); + + fs::write(workspace_root.join("package.json"), workspace_package_json)?; + + // Create project package.json + let project_package_json = self.generate_package_json(config)?; + fs::write(self.project_path.join("package.json"), project_package_json)?; + + // Create project files + let index_js = r#"console.log("Hello from workspace project!"); +console.log("Node version:", process.version); + +// Test workspace dependencies +try { + const deps = require('./package.json').dependencies || {}; + console.log("Dependencies:", Object.keys(deps)); + + // Test specific dependencies + if (deps['adm-zip']) { + const AdmZip = require('adm-zip'); + console.log("Successfully loaded adm-zip from workspace:", typeof AdmZip); + + // Test basic functionality + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.from("workspace test content")); + const entries = zip.getEntries(); + console.log("Zip entries count:", entries.length); + console.log("WORKSPACE_DEPENDENCY_TEST_PASSED"); + } +} catch (e) { + console.error("Workspace dependency test failed:", e.message); + console.log("WORKSPACE_DEPENDENCY_TEST_FAILED"); +} + +console.log("Workspace project test completed!"); +process.exit(0);"#; + + fs::write(self.project_path.join("index.js"), index_js)?; + + Ok(()) + } + + fn create_pnpm_workspace_project(&self, config: &TestProject) -> Result<()> { + let workspace_root = self.workspace_root.as_ref().unwrap(); + fs::create_dir_all(workspace_root)?; + fs::create_dir_all(&self.project_path)?; + + // Create pnpm-workspace.yaml + let pnpm_workspace = format!(r#"packages: + - '{}' +"#, config.name); + + fs::write(workspace_root.join("pnpm-workspace.yaml"), pnpm_workspace)?; + + // Create workspace root package.json + let workspace_package_json = format!(r#"{{ + "name": "test-pnpm-workspace", + "version": "1.0.0", + "private": true, + "dependencies": {{ +{} + }} +}}"#, self.format_dependencies(&config.dependencies)); + + fs::write(workspace_root.join("package.json"), workspace_package_json)?; + + // Create project package.json + let project_package_json = self.generate_package_json(config)?; + fs::write(self.project_path.join("package.json"), project_package_json)?; + + // Create project files (similar to workspace but with pnpm-specific messaging) + let index_js = r#"console.log("Hello from pnpm workspace project!"); +console.log("Node version:", process.version); + +// Test pnpm workspace dependencies +try { + const deps = require('./package.json').dependencies || {}; + console.log("Dependencies:", Object.keys(deps)); + + // Test specific dependencies + if (deps['adm-zip']) { + const AdmZip = require('adm-zip'); + console.log("Successfully loaded adm-zip from pnpm workspace:", typeof AdmZip); + + // Test basic functionality + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.from("pnpm workspace test content")); + const entries = zip.getEntries(); + console.log("Zip entries count:", entries.length); + console.log("PNPM_WORKSPACE_DEPENDENCY_TEST_PASSED"); + } +} catch (e) { + console.error("Pnpm workspace dependency test failed:", e.message); + console.log("PNPM_WORKSPACE_DEPENDENCY_TEST_FAILED"); +} + +console.log("Pnpm workspace project test completed!"); +process.exit(0);"#; + + fs::write(self.project_path.join("index.js"), index_js)?; + + Ok(()) + } + + fn generate_package_json(&self, config: &TestProject) -> Result { + let deps = self.format_dependencies(&config.dependencies); + let dev_deps = self.format_dependencies(&config.dev_dependencies); + + let package_json = format!(r#"{{ + "name": "{}", + "version": "1.0.0", + "main": "index.js", + "scripts": {{ + "start": "node index.js" + }}{}{} +}}"#, + config.name, + if deps.is_empty() { String::new() } else { format!(",\n \"dependencies\": {{\n{}\n }}", deps) }, + if dev_deps.is_empty() { String::new() } else { format!(",\n \"devDependencies\": {{\n{}\n }}", dev_deps) } + ); + + Ok(package_json) + } + + fn format_dependencies(&self, deps: &[(String, String)]) -> String { + deps.iter() + .map(|(name, version)| format!(" \"{}\": \"{}\"", name, version)) + .collect::>() + .join(",\n") + } +} + +/// Bundler test helper for running the bundler in tests +pub struct BundlerTestHelper; + +impl BundlerTestHelper { + /// Get the path to the banderole binary + pub fn get_bundler_path() -> Result { + let target_dir = std::env::current_dir()?.join("target"); + let bundler_path = target_dir.join("debug/banderole"); + + if !bundler_path.exists() { + // Build the bundler if it doesn't exist + println!("Building banderole..."); + let output = Command::new("cargo") + .args(["build"]) + .output()?; + + if !output.status.success() { + anyhow::bail!( + "Failed to build banderole: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + Ok(bundler_path) + } + + /// Bundle a project and return the path to the created executable + pub fn bundle_project( + project_path: &Path, + output_dir: &Path, + custom_name: Option<&str>, + ) -> Result { + let bundler_path = Self::get_bundler_path()?; + + let mut cmd = Command::new(&bundler_path); + cmd.args(["bundle", project_path.to_str().unwrap()]) + .current_dir(output_dir); + + if let Some(name) = custom_name { + cmd.args(["--name", name]); + } + + let bundle_output = Self::run_with_timeout(&mut cmd, Duration::from_secs(300))?; + + if !bundle_output.status.success() { + anyhow::bail!( + "Bundle command failed:\nStdout: {}\nStderr: {}", + String::from_utf8_lossy(&bundle_output.stdout), + String::from_utf8_lossy(&bundle_output.stderr) + ); + } + + // Find the created executable + let executable_name = custom_name.unwrap_or("test-project"); + let executable_path = output_dir.join(if cfg!(windows) { + format!("{}.exe", executable_name) + } else { + executable_name.to_string() + }); + + // Check if collision avoidance was used + if !executable_path.exists() || !executable_path.is_file() { + let bundle_executable_path = output_dir.join(if cfg!(windows) { + format!("{}-bundle.exe", executable_name) + } else { + format!("{}-bundle", executable_name) + }); + + if bundle_executable_path.exists() { + return Ok(bundle_executable_path); + } + } + + if !executable_path.exists() { + anyhow::bail!("Executable was not created at {}", executable_path.display()); + } + + Ok(executable_path) + } + + /// Run an executable and return the output + pub fn run_executable( + executable_path: &Path, + args: &[&str], + env_vars: &[(&str, &str)], + ) -> Result { + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = fs::metadata(executable_path)?.permissions(); + let mut perms = perms.clone(); + perms.set_mode(0o755); + fs::set_permissions(executable_path, perms)?; + } + + let mut cmd = Command::new(executable_path); + cmd.args(args); + + for (key, value) in env_vars { + cmd.env(key, value); + } + + let output = cmd.output()?; + Ok(output) + } + + /// Run a command with a timeout + pub fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Result { + use std::sync::mpsc; + use std::thread; + + let child = cmd.spawn()?; + let (tx, rx) = mpsc::channel(); + + // Spawn a thread to wait for the process + let child_id = child.id(); + thread::spawn(move || { + let result = child.wait_with_output(); + let _ = tx.send(result); + }); + + // Wait for either completion or timeout + match rx.recv_timeout(timeout) { + Ok(result) => result.map_err(|e| anyhow::anyhow!("Command execution failed: {}", e)), + Err(_) => { + // Timeout occurred, kill the process + #[cfg(unix)] + { + let _ = std::process::Command::new("kill") + .args(&["-9", &child_id.to_string()]) + .output(); + } + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(&["/F", "/PID", &child_id.to_string()]) + .output(); + } + + anyhow::bail!("Command timed out after {:?}", timeout) + } + } + } +} + +/// Assertion helpers for test verification +pub struct TestAssertions; + +impl TestAssertions { + /// Assert that the bundled executable runs successfully and produces expected output + pub fn assert_executable_works( + executable_path: &Path, + expected_outputs: &[&str], + env_vars: &[(&str, &str)], + args: &[&str], + ) -> Result<()> { + let output = BundlerTestHelper::run_executable(executable_path, args, env_vars)?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + anyhow::bail!( + "Executable failed with exit code {:?}.\nStdout: {}\nStderr: {}", + output.status.code(), + stdout, + stderr + ); + } + + for expected in expected_outputs { + if !stdout.contains(expected) { + anyhow::bail!( + "Expected output '{}' not found in stdout:\n{}", + expected, + stdout + ); + } + } + + Ok(()) + } + + /// Assert that dependency tests pass in the bundled executable + pub fn assert_dependency_test_passes( + executable_path: &Path, + test_marker: &str, + ) -> Result<()> { + let output = BundlerTestHelper::run_executable(executable_path, &[], &[])?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + anyhow::bail!( + "Dependency test executable failed with exit code {:?}.\nStdout: {}\nStderr: {}", + output.status.code(), + stdout, + stderr + ); + } + + if !stdout.contains(test_marker) { + anyhow::bail!( + "Dependency test failed - marker '{}' not found in output:\n{}", + test_marker, + stdout + ); + } + + Ok(()) + } +} \ No newline at end of file diff --git a/tests/workspace_integration_test.rs b/tests/workspace_integration_test.rs new file mode 100644 index 0000000..0f49327 --- /dev/null +++ b/tests/workspace_integration_test.rs @@ -0,0 +1,439 @@ +mod common; + +use common::{ + BundlerTestHelper, TestAssertions, TestProject, TestProjectManager, +}; +use anyhow::Result; + +#[tokio::test] +async fn test_npm_workspace_dependency_bundling() -> Result<()> { + println!("Testing npm workspace dependency bundling..."); + + // Create a workspace project with dependencies + let project = TestProject::new("workspace-test-app") + .workspace() + .with_dependency("adm-zip", "^0.5.10") + .with_dependency("commander", "^11.0.0"); + + let manager = TestProjectManager::create(project)?; + + // Install dependencies in the workspace root + manager.install_workspace_dependencies()?; + + // Verify that dependencies are installed in workspace root + let workspace_node_modules = manager.workspace_root().unwrap().join("node_modules"); + assert!( + workspace_node_modules.join("adm-zip").exists(), + "adm-zip should be installed in workspace root" + ); + assert!( + workspace_node_modules.join("commander").exists(), + "commander should be installed in workspace root" + ); + + // Bundle the workspace project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("workspace-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from workspace project!", + "Dependencies:", + "adm-zip", + "commander", + "Successfully loaded adm-zip from workspace:", + "WORKSPACE_DEPENDENCY_TEST_PASSED", + ], + &[], + &[], + )?; + + println!("✅ npm workspace dependency bundling test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_pnpm_workspace_dependency_bundling() -> Result<()> { + println!("Testing pnpm workspace dependency bundling..."); + + // Create a pnpm workspace project with dependencies + let project = TestProject::new("pnpm-workspace-test-app") + .pnpm_workspace() + .with_dependency("adm-zip", "^0.5.10") + .with_dependency("js-yaml", "^4.1.0"); + + let manager = TestProjectManager::create(project)?; + + // Try to install dependencies using pnpm, fall back to npm if pnpm is not available + match manager.install_pnpm_dependencies() { + Ok(_) => { + println!("Successfully installed pnpm workspace dependencies"); + } + Err(e) => { + println!("Pnpm installation failed, falling back to npm: {}", e); + manager.install_workspace_dependencies()?; + } + } + + // Bundle the pnpm workspace project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("pnpm-workspace-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from pnpm workspace project!", + "Dependencies:", + "Successfully loaded adm-zip from pnpm workspace:", + "PNPM_WORKSPACE_DEPENDENCY_TEST_PASSED", + ], + &[], + &[], + )?; + + println!("✅ pnpm workspace dependency bundling test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_workspace_with_typescript_project() -> Result<()> { + println!("Testing workspace with TypeScript project..."); + + // Create a workspace TypeScript project + let project = TestProject::new("workspace-ts-app") + .workspace() + .typescript("dist") + .with_dependency("lodash", "^4.17.21") + .with_dependency("@types/lodash", "^4.14.195"); + + let manager = TestProjectManager::create(project)?; + + // Install dependencies + manager.install_workspace_dependencies()?; + + // Bundle the TypeScript workspace project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("workspace-ts-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from TypeScript project!", + "This should come from the compiled output directory", + "Marker file found: dist", + ], + &[], + &[], + )?; + + println!("✅ workspace TypeScript project bundling test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_workspace_nested_dependencies() -> Result<()> { + println!("Testing workspace with nested dependencies..."); + + // Create a workspace project that tests transitive dependency resolution + let project = TestProject::new("nested-deps-app") + .workspace() + .with_dependency("express", "^4.18.2") // Has many transitive dependencies + .with_dependency("axios", "^1.6.0"); // Also has transitive dependencies + + let manager = TestProjectManager::create(project)?; + + // Install dependencies + manager.install_workspace_dependencies()?; + + // Create a more complex index.js that tests nested dependencies + let complex_index_js = r#"console.log("Hello from nested dependencies test!"); + +try { + const express = require('express'); + const axios = require('axios'); + + console.log("Successfully loaded express:", typeof express); + console.log("Successfully loaded axios:", typeof axios); + + // Test that transitive dependencies are available + const app = express(); + console.log("Express app created successfully"); + + // Test axios functionality + console.log("Axios version:", axios.VERSION || "unknown"); + + console.log("NESTED_DEPENDENCIES_TEST_PASSED"); +} catch (e) { + console.error("Nested dependencies test failed:", e.message); + console.log("NESTED_DEPENDENCIES_TEST_FAILED"); + process.exit(1); +} + +console.log("Nested dependencies test completed!"); +process.exit(0);"#; + + std::fs::write(manager.project_path().join("index.js"), complex_index_js)?; + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("nested-deps-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_dependency_test_passes(&executable_path, "NESTED_DEPENDENCIES_TEST_PASSED")?; + + println!("✅ workspace nested dependencies test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_workspace_with_bin_scripts() -> Result<()> { + println!("Testing workspace with bin scripts..."); + + // Create a workspace project with dependencies that have bin scripts + let project = TestProject::new("bin-scripts-app") + .workspace() + .with_dependency("semver", "^7.5.4") // Has a bin script + .with_dependency("rimraf", "^5.0.5"); // Has a bin script + + let manager = TestProjectManager::create(project)?; + + // Install dependencies + manager.install_workspace_dependencies()?; + + // Verify that .bin directory exists in workspace + let workspace_bin = manager.workspace_root().unwrap().join("node_modules/.bin"); + assert!( + workspace_bin.exists(), + ".bin directory should exist in workspace node_modules" + ); + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("bin-scripts-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from workspace project!", + "Dependencies:", + "Workspace project test completed!", + ], + &[], + &[], + )?; + + println!("✅ workspace bin scripts test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_workspace_project_without_own_node_modules() -> Result<()> { + println!("Testing workspace project without its own node_modules..."); + + // Create a workspace project where dependencies are only in workspace root + let project = TestProject::new("no-local-deps-app") + .workspace() + .with_dependency("minimist", "^1.2.8") + .with_dependency("chalk", "^5.3.0"); + + let manager = TestProjectManager::create(project)?; + + // Install dependencies only in workspace root + manager.install_workspace_dependencies()?; + + // Ensure the project itself has no node_modules + let project_node_modules = manager.project_path().join("node_modules"); + if project_node_modules.exists() { + std::fs::remove_dir_all(&project_node_modules)?; + } + + // Verify workspace has the dependencies + let workspace_node_modules = manager.workspace_root().unwrap().join("node_modules"); + assert!( + workspace_node_modules.join("minimist").exists(), + "minimist should be in workspace node_modules" + ); + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("no-local-deps-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from workspace project!", + "Dependencies:", + "Workspace project test completed!", + ], + &[], + &[], + )?; + + println!("✅ workspace project without local node_modules test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_workspace_with_peer_dependencies() -> Result<()> { + println!("Testing workspace with peer dependencies..."); + + // Create a project that has peer dependencies + let project = TestProject::new("peer-deps-app") + .workspace() + .with_dependency("react", "^18.2.0") + .with_dependency("prop-types", "^15.8.1"); // Has react as peer dependency + + let manager = TestProjectManager::create(project)?; + + // Create a custom package.json that includes peerDependencies + let package_json_with_peers = r#"{ + "name": "peer-deps-app", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "react": "^18.2.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^18.0.0" + } +}"#; + + std::fs::write(manager.project_path().join("package.json"), package_json_with_peers)?; + + // Install dependencies + manager.install_workspace_dependencies()?; + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("peer-deps-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from workspace project!", + "Dependencies:", + "Workspace project test completed!", + ], + &[], + &[], + )?; + + println!("✅ workspace peer dependencies test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_deep_workspace_nesting() -> Result<()> { + println!("Testing deep workspace nesting..."); + + // Create a deeply nested workspace structure + let project = TestProject::new("apps/frontend/client") + .workspace() + .with_dependency("uuid", "^9.0.1") + .with_dependency("date-fns", "^2.30.0"); + + let manager = TestProjectManager::create(project)?; + + // Install dependencies in workspace root + manager.install_workspace_dependencies()?; + + // Bundle the deeply nested project + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("deep-nested-test"), + )?; + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from workspace project!", + "Dependencies:", + "Workspace project test completed!", + ], + &[], + &[], + )?; + + println!("✅ deep workspace nesting test passed!"); + Ok(()) +} + +#[tokio::test] +async fn test_workspace_collision_handling() -> Result<()> { + println!("Testing workspace collision handling..."); + + // Create a workspace project where the executable name might collide + let project = TestProject::new("collision-test") + .workspace() + .with_dependency("fs-extra", "^11.1.1"); + + let manager = TestProjectManager::create(project)?; + + // Create a directory with the same name as the expected executable + std::fs::create_dir_all(manager.temp_dir().join("collision-test"))?; + + // Install dependencies + manager.install_workspace_dependencies()?; + + // Bundle the project (should handle collision automatically) + let executable_path = BundlerTestHelper::bundle_project( + manager.project_path(), + manager.temp_dir(), + Some("collision-test"), + )?; + + // The executable should exist with collision avoidance + assert!( + executable_path.exists(), + "Executable should exist with collision avoidance: {}", + executable_path.display() + ); + + // Test the bundled executable + TestAssertions::assert_executable_works( + &executable_path, + &[ + "Hello from workspace project!", + "Workspace project test completed!", + ], + &[], + &[], + )?; + + println!("✅ workspace collision handling test passed!"); + Ok(()) +} \ No newline at end of file