diff --git a/Cargo.lock b/Cargo.lock index a298166..1251024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tokio", @@ -478,6 +479,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -494,6 +510,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -529,6 +556,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -729,7 +757,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -1419,6 +1447,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.27" @@ -1434,6 +1471,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "security-framework" version = "2.11.1" @@ -1501,6 +1544,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1566,6 +1634,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1713,7 +1791,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.5.10", "tokio-macros", "windows-sys 0.52.0", ] diff --git a/Cargo.toml b/Cargo.toml index d01625b..56474e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,10 @@ harness = true name = "workspace_integration_test" harness = true +[[test]] +name = "concurrent_execution_integration_test" +harness = true + # Run tests sequentially to avoid resource conflicts [profile.test] opt-level = 0 diff --git a/src/bundler.rs b/src/bundler.rs index 603f49b..c8deb10 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -1,43 +1,56 @@ use crate::node_downloader::NodeDownloader; use crate::platform::Platform; use anyhow::{Context, Result}; +use base64::Engine as _; use serde_json::Value; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use uuid::Uuid; -use base64::Engine as _; use zip::ZipWriter; - /// Public entry-point used by `main.rs`. /// /// * `project_path` – path that contains a `package.json`. /// * `output_path` – optional path to the produced bundle file. If omitted, an /// automatically-generated name is used. /// * `custom_name` – optional custom name for the executable. +/// * `no_compression` – disable compression for faster bundling (useful for testing). /// /// The implementation uses a simpler, more reliable approach based on Playwright's bundling strategy. -pub async fn bundle_project(project_path: PathBuf, output_path: Option, custom_name: Option) -> Result<()> { +pub async fn bundle_project( + project_path: PathBuf, + output_path: Option, + custom_name: Option, + no_compression: bool, +) -> Result<()> { // 1. Validate & canonicalize input directory. let project_path = project_path .canonicalize() .context("Failed to resolve project path")?; let pkg_json = project_path.join("package.json"); - anyhow::ensure!(pkg_json.exists(), "package.json not found in {}", project_path.display()); + anyhow::ensure!( + pkg_json.exists(), + "package.json not found in {}", + project_path.display() + ); // 2. Read `package.json` for name/version and detect project structure let package_content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?; - let package_value: Value = serde_json::from_str(&package_content).context("Failed to parse package.json")?; - + let package_value: Value = + serde_json::from_str(&package_content).context("Failed to parse package.json")?; + let (app_name, app_version) = ( package_value["name"].as_str().unwrap_or("app").to_string(), - package_value["version"].as_str().unwrap_or("0.0.0").to_string(), + package_value["version"] + .as_str() + .unwrap_or("0.0.0") + .to_string(), ); // 3. Determine the correct source directory to bundle let source_dir = determine_source_directory(&project_path, &package_value)?; - + // 4. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1). let node_version = detect_node_version(&project_path).unwrap_or_else(|_| "22.17.1".into()); @@ -45,7 +58,7 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option, "Bundling {app_name} v{app_version} using Node.js v{node_version} for {plat}", plat = Platform::current() ); - + if source_dir != project_path { println!("Using source directory: {}", source_dir.display()); } @@ -66,16 +79,20 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option, let mut zip_data: Vec = Vec::new(); { let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data)); - let opts: zip::write::FileOptions<'static, ()> = zip::write::FileOptions::default() - .compression_method(zip::CompressionMethod::Deflated) - .compression_level(Some(8)); + let opts: zip::write::FileOptions<'static, ()> = if no_compression { + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored) + } else { + zip::write::FileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .compression_level(Some(8)) + }; // Copy the determined source directory (excluding node_modules to avoid conflicts) add_dir_to_zip_excluding_node_modules(&mut zip, &source_dir, Path::new("app"), opts)?; - + // Handle dependencies and package.json bundle_dependencies(&mut zip, &project_path, &source_dir, &package_value, opts)?; - + // Copy Node runtime directory. add_dir_to_zip(&mut zip, node_root, Path::new("node"), opts)?; zip.finish()?; @@ -104,19 +121,22 @@ where let root_package_json = project_path.join("package.json"); if root_package_json.exists() { zip.start_file("app/package.json", opts)?; - + // Read and modify package.json to adjust the main path - let content = fs::read_to_string(&root_package_json).context("Failed to read root package.json")?; - let mut package_value: Value = serde_json::from_str(&content).context("Failed to parse root package.json")?; - + let content = fs::read_to_string(&root_package_json) + .context("Failed to read root package.json")?; + let mut package_value: Value = + serde_json::from_str(&content).context("Failed to parse root package.json")?; + // Adjust the main field if it points to the source directory if let Some(main) = package_value["main"].as_str() { let main_path = project_path.join(main); if let Ok(relative_to_source) = main_path.strip_prefix(&source_dir) { - package_value["main"] = Value::String(relative_to_source.to_string_lossy().to_string()); + package_value["main"] = + Value::String(relative_to_source.to_string_lossy().to_string()); } } - + let modified_content = serde_json::to_string_pretty(&package_value) .context("Failed to serialize modified package.json")?; zip.write_all(modified_content.as_bytes())?; @@ -125,19 +145,19 @@ where // Find and bundle dependencies with improved package manager support let deps_result = find_and_bundle_dependencies(zip, project_path, opts)?; - + // Log the result if deps_result.dependencies_found { println!("Bundled dependencies: {}", deps_result.source_description); } else { println!("Warning: No dependencies found to bundle"); } - + // Log any warnings for warning in &deps_result.warnings { println!("Warning: {}", warning); } - + Ok(()) } @@ -157,12 +177,12 @@ where W: Write + Read + std::io::Seek, { let mut warnings = Vec::new(); - + // Strategy 1: Check for node_modules in the project directory let project_node_modules = project_path.join("node_modules"); if project_node_modules.exists() { let package_manager = detect_package_manager(&project_node_modules, project_path); - + // 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 @@ -185,7 +205,7 @@ where } else { false }; - + // If it's a pnpm workspace, skip local bundling and go to workspace detection if !is_pnpm_workspace { match package_manager { @@ -200,7 +220,12 @@ where } PackageManager::Yarn => { // For yarn, bundle node_modules with comprehensive dependency resolution - bundle_node_modules_comprehensive(zip, &project_node_modules, project_path, opts)?; + 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(), @@ -209,7 +234,12 @@ where } PackageManager::Npm | PackageManager::Unknown => { // For npm or unknown, use comprehensive bundling - bundle_node_modules_comprehensive(zip, &project_node_modules, project_path, opts)?; + 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(), @@ -225,54 +255,69 @@ where while let Some(parent_path) = current_path { let parent_node_modules = parent_path.join("node_modules"); let parent_package_json = parent_path.join("package.json"); - + 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) { - is_workspace = pkg_value["workspaces"].is_array() + 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())); - + 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()), + source_description: format!( + "workspace pnpm dependencies from {}", + parent_path.display() + ), warnings, }); } _ => { - bundle_workspace_dependencies(zip, &parent_node_modules, parent_path, project_path, opts)?; + 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()), + source_description: format!( + "workspace dependencies from {}", + parent_path.display() + ), warnings, }); } } } } - + current_path = parent_path.parent(); - + // Don't go too far up the directory tree if parent_path.components().count() < 2 { break; @@ -300,7 +345,7 @@ fn detect_package_manager(node_modules_path: &Path, project_path: &Path) -> Pack if node_modules_path.join(".pnpm").exists() { 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) { @@ -316,20 +361,20 @@ fn detect_package_manager(node_modules_path: &Path, project_path: &Path) -> Pack } } } - + // Check for lockfiles in the project directory if project_path.join("pnpm-lock.yaml").exists() { return PackageManager::Pnpm; } - + if project_path.join("yarn.lock").exists() { return PackageManager::Yarn; } - + if project_path.join("package-lock.json").exists() { return PackageManager::Npm; } - + PackageManager::Unknown } @@ -344,7 +389,7 @@ where { let node_modules_path = project_path.join("node_modules"); let pnpm_dir = node_modules_path.join(".pnpm"); - + if !pnpm_dir.exists() { // If no .pnpm directory, fall back to simple copy if node_modules_path.exists() { @@ -355,7 +400,7 @@ where // For pnpm, use a smarter approach that only includes actually needed packages 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) { @@ -369,7 +414,7 @@ where // For now, skip them to keep the bundle smaller } } - + // Recursively resolve dependencies for each package let mut resolved_packages = std::collections::HashSet::new(); for package_name in &packages_to_bundle { @@ -382,14 +427,19 @@ where )?; } - println!("Bundling {} packages (resolved dependencies) for pnpm project", resolved_packages.len()); + println!( + "Bundling {} packages (resolved dependencies) for pnpm project", + resolved_packages.len() + ); // Ensure app/node_modules directory exists zip.add_directory("app/node_modules/", opts)?; // Copy each resolved package for package_name in &resolved_packages { - if let Err(e) = copy_pnpm_package_comprehensive(zip, &node_modules_path, &pnpm_dir, package_name, opts) { + 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); } } @@ -427,49 +477,68 @@ fn resolve_package_dependencies( 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_json_content = match find_package_json_content(node_modules_path, pnpm_dir, package_name) { - Ok(content) => content, - Err(_) => return Ok(()), // Skip packages we can't find - }; - + let package_json_content = + match find_package_json_content(node_modules_path, pnpm_dir, package_name) { + Ok(content) => content, + Err(_) => return Ok(()), // Skip packages we can't find + }; + 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_package_dependencies(node_modules_path, pnpm_dir, dep_name, resolved, depth + 1)?; + resolve_package_dependencies( + node_modules_path, + pnpm_dir, + 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() { // Only include if it actually exists if package_exists_in_pnpm(node_modules_path, pnpm_dir, dep_name) { - resolve_package_dependencies(node_modules_path, pnpm_dir, dep_name, resolved, depth + 1)?; + resolve_package_dependencies( + node_modules_path, + pnpm_dir, + dep_name, + resolved, + depth + 1, + )?; } } } - + // Also include optionalDependencies that are actually installed (important for native bindings) if let Some(optional_deps) = package_json["optionalDependencies"].as_object() { for dep_name in optional_deps.keys() { // Only include if it actually exists if package_exists_in_pnpm(node_modules_path, pnpm_dir, dep_name) { - resolve_package_dependencies(node_modules_path, pnpm_dir, dep_name, resolved, depth + 1)?; + resolve_package_dependencies( + node_modules_path, + pnpm_dir, + dep_name, + resolved, + depth + 1, + )?; } } } } - + Ok(()) } @@ -487,48 +556,49 @@ fn find_package_json_content( if target.is_absolute() { target } else { - top_level_package.parent().unwrap().join(target).canonicalize()? + top_level_package + .parent() + .unwrap() + .join(target) + .canonicalize()? } } else { top_level_package }; - + let package_json_path = target_path.join("package.json"); if package_json_path.exists() { return fs::read_to_string(&package_json_path).context("Failed to read package.json"); } } - + // Try .pnpm directory for entry in fs::read_dir(pnpm_dir)? { let entry = entry?; let pnpm_package_name = entry.file_name().to_string_lossy().to_string(); - + if let Some(extracted_name) = extract_package_name_from_pnpm(&pnpm_package_name) { if extracted_name == package_name { let pnpm_package_path = entry.path().join("node_modules").join(package_name); let package_json_path = pnpm_package_path.join("package.json"); if package_json_path.exists() { - return fs::read_to_string(&package_json_path).context("Failed to read package.json"); + return fs::read_to_string(&package_json_path) + .context("Failed to read package.json"); } } } } - + anyhow::bail!("Could not find package.json for {}", package_name) } /// Check if a package exists in the pnpm structure -fn package_exists_in_pnpm( - node_modules_path: &Path, - pnpm_dir: &Path, - package_name: &str, -) -> bool { +fn package_exists_in_pnpm(node_modules_path: &Path, pnpm_dir: &Path, package_name: &str) -> bool { // Check top-level if node_modules_path.join(package_name).exists() { return true; } - + // Check .pnpm if let Ok(entries) = fs::read_dir(pnpm_dir) { for entry in entries.flatten() { @@ -540,7 +610,7 @@ fn package_exists_in_pnpm( } } } - + false } @@ -549,7 +619,8 @@ fn extract_package_name_from_pnpm(pnpm_name: &str) -> Option { // Handle scoped packages like "@sindresorhus+is@4.6.0" -> "@sindresorhus/is" if pnpm_name.starts_with('@') { if let Some(at_pos) = pnpm_name.rfind('@') { - if at_pos > 0 { // Make sure it's not the first @ + if at_pos > 0 { + // Make sure it's not the first @ let package_part = &pnpm_name[..at_pos]; // Convert + back to / for scoped packages return Some(package_part.replace('+', "/")); @@ -558,7 +629,7 @@ fn extract_package_name_from_pnpm(pnpm_name: &str) -> Option { // If no version found, just convert + to / return Some(pnpm_name.replace('+', "/")); } - + // Handle regular packages like "adm-zip@0.5.16" if let Some(at_pos) = pnpm_name.find('@') { Some(pnpm_name[..at_pos].to_string()) @@ -579,7 +650,7 @@ where W: Write + Read + std::io::Seek, { let dest_path = Path::new("app/node_modules").join(package_name); - + // First try to find it as a top-level package let top_level_package = node_modules_path.join(package_name); if top_level_package.exists() { @@ -589,29 +660,38 @@ where if target.is_absolute() { target } else { - top_level_package.parent().unwrap().join(target).canonicalize()? + top_level_package + .parent() + .unwrap() + .join(target) + .canonicalize()? } } else { top_level_package }; - + if target_path.exists() { add_dir_to_zip_no_follow_skip_parents(zip, &target_path, &dest_path, opts)?; return Ok(()); } } - + // If not found at top level, search in .pnpm directory for entry in fs::read_dir(pnpm_dir)? { let entry = entry?; let pnpm_package_name = entry.file_name().to_string_lossy().to_string(); - + // Check if this .pnpm entry matches our package name if let Some(extracted_name) = extract_package_name_from_pnpm(&pnpm_package_name) { if extracted_name == package_name { let pnpm_package_path = entry.path().join("node_modules").join(package_name); if pnpm_package_path.exists() { - add_dir_to_zip_no_follow_skip_parents(zip, &pnpm_package_path, &dest_path, opts)?; + add_dir_to_zip_no_follow_skip_parents( + zip, + &pnpm_package_path, + &dest_path, + opts, + )?; return Ok(()); } } @@ -621,8 +701,6 @@ where Ok(()) } - - /// Bundle node_modules with comprehensive dependency resolution fn bundle_node_modules_comprehensive( zip: &mut ZipWriter, @@ -673,14 +751,23 @@ where )?; } - println!("Bundling {} packages (resolved dependencies) for pnpm node_modules", resolved_packages.len()); + 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) { + 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); } } @@ -696,7 +783,10 @@ where )?; } - println!("Bundling {} packages (resolved dependencies) for regular node_modules", resolved_packages.len()); + 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)?; @@ -777,7 +867,10 @@ where )?; } - println!("Bundling {} packages (resolved dependencies) for workspace node_modules", resolved_packages.len()); + 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)?; @@ -857,14 +950,23 @@ where )?; } - println!("Bundling {} packages (resolved dependencies) for workspace pnpm node_modules", resolved_packages.len()); + 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) { + 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); } } @@ -914,10 +1016,11 @@ fn determine_source_directory(project_path: &Path, package_json: &Value) -> Resu let main_path = project_path.join(main); if let Some(parent) = main_path.parent() { // If main points to a file in a subdirectory like dist/index.js or build/index.js - let parent_name = parent.file_name() + let parent_name = parent + .file_name() .and_then(|name| name.to_str()) .unwrap_or(""); - + if ["dist", "build", "lib", "out"].contains(&parent_name) && parent.exists() { return Ok(parent.to_path_buf()); } @@ -954,18 +1057,17 @@ fn determine_source_directory(project_path: &Path, package_json: &Value) -> Resu /// Read and parse tsconfig.json, handling extends configuration fn read_tsconfig(tsconfig_path: &Path) -> Result { - let content = fs::read_to_string(tsconfig_path) - .context("Failed to read tsconfig.json")?; - + let content = fs::read_to_string(tsconfig_path).context("Failed to read tsconfig.json")?; + // Remove comments for JSON parsing (simple approach) let cleaned_content = content .lines() .filter(|line| !line.trim_start().starts_with("//")) .collect::>() .join("\n"); - - let mut config: Value = serde_json::from_str(&cleaned_content) - .context("Failed to parse tsconfig.json")?; + + let mut config: Value = + serde_json::from_str(&cleaned_content).context("Failed to parse tsconfig.json")?; // Handle extends configuration if let Some(extends) = config["extends"].as_str() { @@ -986,7 +1088,9 @@ fn read_tsconfig(tsconfig_path: &Path) -> Result { if base_path.exists() { if let Ok(base_config) = read_tsconfig(&base_path) { // Merge base config with current config (simple merge) - if let (Some(base_obj), Some(current_obj)) = (base_config.as_object(), config.as_object()) { + if let (Some(base_obj), Some(current_obj)) = + (base_config.as_object(), config.as_object()) + { let mut merged = base_obj.clone(); for (key, value) in current_obj { merged.insert(key.clone(), value.clone()); @@ -1025,7 +1129,11 @@ fn resolve_output_path( return Ok(path); } - let ext = if Platform::current().is_windows() { ".exe" } else { "" }; + let ext = if Platform::current().is_windows() { + ".exe" + } else { + "" + }; let base_name = custom_name.unwrap_or(app_name); let mut output_path = PathBuf::from(format!("{base_name}{ext}")); @@ -1039,7 +1147,7 @@ fn resolve_output_path( break; } } - + // If it still exists (file or another directory), add a counter if output_path.exists() { output_path = PathBuf::from(format!("{base_name}-bundle-{counter}{ext}")); @@ -1056,7 +1164,7 @@ fn resolve_output_path( fn create_self_extracting_executable(out: &Path, zip_data: Vec, _app_name: &str) -> Result<()> { let build_id = Uuid::new_v4(); - + if Platform::current().is_windows() { create_windows_executable(out, zip_data, &build_id.to_string()) } else { @@ -1069,8 +1177,9 @@ fn create_unix_executable(out: &Path, zip_data: Vec, build_id: &str) -> Resu let mut file = fs::File::create(out).context("Failed to create output executable")?; - // Write a simpler, more reliable shell script - let script = format!(r#"#!/bin/bash + // Write a shell script with improved queue system for concurrent execution + let script = format!( + r#"#!/bin/bash set -e # Determine cache directory using directories pattern @@ -1083,10 +1192,13 @@ else fi APP_DIR="$CACHE_DIR/{}" +LOCK_FILE="$APP_DIR/.lock" +READY_FILE="$APP_DIR/.ready" +QUEUE_DIR="$APP_DIR/.queue" +EXTRACTION_PID_FILE="$APP_DIR/.extraction.pid" -# Check if already extracted and ready -if [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then - # Already extracted, run directly +# Function to run the application +run_app() {{ cd "$APP_DIR/app" # Find main script from package.json @@ -1098,54 +1210,172 @@ if [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then echo "Error: Main script '$MAIN_SCRIPT' not found" >&2 exit 1 fi +}} + +# Function to clean up queue entry +cleanup_queue() {{ + if [ -n "$QUEUE_ENTRY" ] && [ -f "$QUEUE_ENTRY" ]; then + rm -f "$QUEUE_ENTRY" 2>/dev/null || true + fi +}} + +# Set up cleanup trap +trap cleanup_queue EXIT + +# Check if already extracted and ready +if [ -f "$READY_FILE" ] && [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then + # Already extracted, run directly + run_app "$@" fi -# Extract application -mkdir -p "$APP_DIR" +# Create queue directory if it doesn't exist +mkdir -p "$QUEUE_DIR" 2>/dev/null || true -# Create a temporary file for the zip data -TEMP_ZIP=$(mktemp) -trap "rm -f '$TEMP_ZIP'" EXIT +# Generate unique queue entry +QUEUE_ENTRY="$QUEUE_DIR/$$-$(date +%s%N)" +echo "$$" > "$QUEUE_ENTRY" -# Extract embedded zip data (everything after the __DATA__ marker) -awk '/^__DATA__$/{{p=1;next}} p{{print}}' "$0" | base64 -d > "$TEMP_ZIP" +# Try to acquire extraction lock +( + # Use flock if available, otherwise use mkdir as fallback + if command -v flock >/dev/null 2>&1; then + exec 200>"$LOCK_FILE" + if ! flock -n 200; then + # Another process is extracting, wait in queue + while [ ! -f "$READY_FILE" ]; do + # Check if extraction process is still alive + if [ -f "$EXTRACTION_PID_FILE" ]; then + EXTRACTION_PID=$(cat "$EXTRACTION_PID_FILE" 2>/dev/null || echo "") + if [ -n "$EXTRACTION_PID" ] && ! kill -0 "$EXTRACTION_PID" 2>/dev/null; then + # Extraction process died, clean up and try again + rm -f "$EXTRACTION_PID_FILE" "$LOCK_FILE" 2>/dev/null || true + break + fi + fi + sleep 0.1 + done + + # Wait for our turn in the queue + while [ -f "$QUEUE_ENTRY" ]; do + if [ -f "$READY_FILE" ]; then + break + fi + + # Check if we're the first in queue + FIRST_QUEUE=$(ls -1 "$QUEUE_DIR" 2>/dev/null | head -n1 || echo "") + if [ "$(basename "$QUEUE_ENTRY")" = "$FIRST_QUEUE" ]; then + break + fi + sleep 0.05 + done + + run_app "$@" + fi + + # We got the lock, record our PID for extraction + echo "$$" > "$EXTRACTION_PID_FILE" + else + # Fallback to mkdir-based locking + while ! mkdir "$LOCK_FILE" 2>/dev/null; do + if [ -f "$READY_FILE" ]; then + # Wait for our turn in the queue + while [ -f "$QUEUE_ENTRY" ]; do + if [ -f "$READY_FILE" ]; then + break + fi + + # Check if we're the first in queue + FIRST_QUEUE=$(ls -1 "$QUEUE_DIR" 2>/dev/null | head -n1 || echo "") + if [ "$(basename "$QUEUE_ENTRY")" = "$FIRST_QUEUE" ]; then + break + fi + sleep 0.05 + done + + run_app "$@" + fi + sleep 0.1 + done + trap "rmdir '$LOCK_FILE' 2>/dev/null || true; rm -f '$EXTRACTION_PID_FILE' 2>/dev/null || true" EXIT + + # We got the lock, record our PID for extraction + echo "$$" > "$EXTRACTION_PID_FILE" + fi -# Verify we got valid zip data -if [ ! -s "$TEMP_ZIP" ]; then - echo "Error: Failed to extract bundle data" >&2 - exit 1 -fi + # Check again if extraction completed while we were waiting for lock + if [ -f "$READY_FILE" ] && [ -f "$APP_DIR/app/package.json" ] && [ -x "$APP_DIR/node/bin/node" ]; then + run_app "$@" + fi -# Extract the bundle -if ! unzip -q "$TEMP_ZIP" -d "$APP_DIR" 2>/dev/null; then - echo "Error: Failed to extract bundle" >&2 - rm -rf "$APP_DIR" - exit 1 -fi + # Extract application + mkdir -p "$APP_DIR" -# Verify extraction worked -if [ ! -f "$APP_DIR/app/package.json" ] || [ ! -x "$APP_DIR/node/bin/node" ]; then - echo "Error: Bundle extraction incomplete" >&2 - rm -rf "$APP_DIR" - exit 1 -fi + # Create a temporary file for the zip data + TEMP_ZIP=$(mktemp) + trap "rm -f '$TEMP_ZIP'" EXIT + + # Extract embedded zip data (everything after the __DATA__ marker) + awk '/^__DATA__$/{{p=1;next}} p{{print}}' "$0" | base64 -d > "$TEMP_ZIP" + + # Verify we got valid zip data + if [ ! -s "$TEMP_ZIP" ]; then + echo "Error: Failed to extract bundle data" >&2 + rm -rf "$APP_DIR" + exit 1 + fi + + # Extract the bundle + if ! unzip -q "$TEMP_ZIP" -d "$APP_DIR" 2>/dev/null; then + echo "Error: Failed to extract bundle" >&2 + rm -rf "$APP_DIR" + exit 1 + fi + + # Verify extraction worked + if [ ! -f "$APP_DIR/app/package.json" ] || [ ! -x "$APP_DIR/node/bin/node" ]; then + echo "Error: Bundle extraction incomplete" >&2 + rm -rf "$APP_DIR" + exit 1 + fi + + # Mark as ready for other processes + touch "$READY_FILE" + + # Process queue in order - wake up waiting processes + if [ -d "$QUEUE_DIR" ]; then + for queue_file in $(ls -1 "$QUEUE_DIR" 2>/dev/null | sort); do + queue_path="$QUEUE_DIR/$queue_file" + if [ -f "$queue_path" ]; then + queue_pid=$(cat "$queue_path" 2>/dev/null || echo "") + if [ -n "$queue_pid" ] && kill -0 "$queue_pid" 2>/dev/null; then + # Signal the waiting process by removing its queue file + rm -f "$queue_path" 2>/dev/null || true + fi + fi + done + fi + + # Clean up extraction metadata + rm -f "$EXTRACTION_PID_FILE" 2>/dev/null || true + + # Clean up lock + if command -v flock >/dev/null 2>&1; then + exec 200>&- + else + rmdir "$LOCK_FILE" 2>/dev/null || true + fi +) # Run the application -cd "$APP_DIR/app" -MAIN_SCRIPT=$("$APP_DIR/node/bin/node" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2>/dev/null || echo "index.js") - -if [ -f "$MAIN_SCRIPT" ]; then - exec "$APP_DIR/node/bin/node" "$MAIN_SCRIPT" "$@" -else - echo "Error: Main script '$MAIN_SCRIPT' not found" >&2 - exit 1 -fi +run_app "$@" __DATA__ -"#, build_id); +"#, + build_id + ); file.write_all(script.as_bytes())?; - + // Append base64-encoded zip data let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data); file.write_all(encoded.as_bytes())?; @@ -1155,38 +1385,102 @@ __DATA__ let mut perms = file.metadata()?.permissions(); perms.set_mode(0o755); fs::set_permissions(out, perms)?; - + Ok(()) } fn create_windows_executable(out: &Path, zip_data: Vec, build_id: &str) -> Result<()> { let mut file = fs::File::create(out).context("Failed to create output executable")?; - // Create a more reliable Windows batch script - let script = format!(r#"@echo off + // Create a Windows batch script with improved queue system for concurrent execution + let script = format!( + r#"@echo off setlocal enabledelayedexpansion REM Determine cache directory set "CACHE_DIR=%LOCALAPPDATA%\banderole" set "APP_DIR=!CACHE_DIR!\{}" +set "LOCK_FILE=!APP_DIR!\.lock" +set "READY_FILE=!APP_DIR!\.ready" +set "QUEUE_DIR=!APP_DIR!\.queue" +set "EXTRACTION_PID_FILE=!APP_DIR!\.extraction.pid" + +REM Function to run the application +:run_app +cd /d "!APP_DIR!\app" + +REM Find main script +for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i" +if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js" + +if exist "!MAIN_SCRIPT!" ( + "!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %* + exit /b !errorlevel! +) else ( + echo Error: Main script '!MAIN_SCRIPT!' not found >&2 + exit /b 1 +) + +REM Function to clean up queue entry +:cleanup_queue +if defined QUEUE_ENTRY if exist "!QUEUE_ENTRY!" ( + del "!QUEUE_ENTRY!" 2>nul +) +exit /b REM Check if already extracted and ready -if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" ( - cd /d "!APP_DIR!\app" - - REM Find main script - for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i" - if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js" - - if exist "!MAIN_SCRIPT!" ( - "!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %* - exit /b !errorlevel! - ) else ( - echo Error: Main script '!MAIN_SCRIPT!' not found >&2 - exit /b 1 +if exist "!READY_FILE!" if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" ( + goto run_app +) + +REM Create queue directory if it doesn't exist +if not exist "!QUEUE_DIR!" mkdir "!QUEUE_DIR!" 2>nul + +REM Generate unique queue entry +set "QUEUE_ENTRY=!QUEUE_DIR!\%RANDOM%-%TIME:~6,5%.queue" +echo %RANDOM% > "!QUEUE_ENTRY!" + +REM Try to acquire lock for extraction +:acquire_lock +if not exist "!LOCK_FILE!" ( + mkdir "!LOCK_FILE!" 2>nul + if !errorlevel! equ 0 ( + REM We got the lock, record our PID for extraction + echo !RANDOM! > "!EXTRACTION_PID_FILE!" + goto extract ) ) +REM Another process is extracting, wait in queue +:wait_for_ready +if exist "!READY_FILE!" ( + REM Wait for our turn in the queue + :wait_queue_turn + if not exist "!QUEUE_ENTRY!" goto run_app + if exist "!READY_FILE!" goto run_app + + REM Check if we're first in queue (simplified check) + timeout /t 1 /nobreak >nul 2>&1 + goto wait_queue_turn +) + +REM Check if extraction process is still alive (simplified for Windows) +if exist "!EXTRACTION_PID_FILE!" ( + timeout /t 1 /nobreak >nul 2>&1 + goto wait_for_ready +) else ( + REM Extraction process may have died, try to acquire lock again + goto acquire_lock +) + +:extract +REM Check again if extraction completed while we were waiting for lock +if exist "!READY_FILE!" if exist "!APP_DIR!\app\package.json" if exist "!APP_DIR!\node\node.exe" ( + call :cleanup_queue + rmdir "!LOCK_FILE!" 2>nul + goto run_app +) + REM Extract application if not exist "!CACHE_DIR!" mkdir "!CACHE_DIR!" if not exist "!APP_DIR!" mkdir "!APP_DIR!" @@ -1199,6 +1493,9 @@ powershell -NoProfile -Command "$content = Get-Content '%~f0' -Raw; $dataStart = if not exist "%TEMP_ZIP%" ( echo Error: Failed to extract bundle data >&2 + call :cleanup_queue + rmdir "!LOCK_FILE!" 2>nul + del "!EXTRACTION_PID_FILE!" 2>nul exit /b 1 ) @@ -1209,45 +1506,63 @@ del "%TEMP_ZIP%" 2>nul if !EXTRACT_RESULT! neq 0 ( echo Error: Failed to extract bundle >&2 + call :cleanup_queue rmdir /s /q "!APP_DIR!" 2>nul + rmdir "!LOCK_FILE!" 2>nul + del "!EXTRACTION_PID_FILE!" 2>nul exit /b 1 ) REM Verify extraction worked if not exist "!APP_DIR!\app\package.json" ( echo Error: Bundle extraction incomplete >&2 + call :cleanup_queue rmdir /s /q "!APP_DIR!" 2>nul + rmdir "!LOCK_FILE!" 2>nul + del "!EXTRACTION_PID_FILE!" 2>nul exit /b 1 ) if not exist "!APP_DIR!\node\node.exe" ( echo Error: Node.js executable not found >&2 + call :cleanup_queue rmdir /s /q "!APP_DIR!" 2>nul + rmdir "!LOCK_FILE!" 2>nul + del "!EXTRACTION_PID_FILE!" 2>nul exit /b 1 ) +REM Mark as ready for other processes +echo ready > "!READY_FILE!" + +REM Process queue in order - clean up queue files to wake up waiting processes +if exist "!QUEUE_DIR!" ( + for %%f in ("!QUEUE_DIR!\*.queue") do ( + if exist "%%f" del "%%f" 2>nul + ) +) + +REM Clean up extraction metadata +del "!EXTRACTION_PID_FILE!" 2>nul +call :cleanup_queue + +REM Clean up lock +rmdir "!LOCK_FILE!" 2>nul + REM Run the application -cd /d "!APP_DIR!\app" -for /f "delims=" %%i in ('"!APP_DIR!\node\node.exe" -e "try {{ console.log(require('./package.json').main || 'index.js'); }} catch(e) {{ console.log('index.js'); }}" 2^>nul') do set "MAIN_SCRIPT=%%i" -if "!MAIN_SCRIPT!"=="" set "MAIN_SCRIPT=index.js" - -if exist "!MAIN_SCRIPT!" ( - "!APP_DIR!\node\node.exe" "!MAIN_SCRIPT!" %* - exit /b !errorlevel! -) else ( - echo Error: Main script '!MAIN_SCRIPT!' not found >&2 - exit /b 1 -) +goto run_app __DATA__ -"#, build_id); +"#, + build_id + ); file.write_all(script.as_bytes())?; - + // Append base64-encoded zip data let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data); file.write_all(encoded.as_bytes())?; - + Ok(()) } @@ -1295,7 +1610,7 @@ where opts } }; - + zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?; let data = fs::read(path).context("Failed to read file while zipping")?; zip.write_all(&data)?; @@ -1344,9 +1659,9 @@ where opts } }; - + zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?; - + if entry.file_type().is_symlink() { // For symlinks, read the target and store it as file content // This won't create actual symlinks but avoids infinite loops @@ -1407,9 +1722,9 @@ where opts } }; - + zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?; - + if entry.file_type().is_symlink() { // For symlinks, read the target and store it as file content // This won't create actual symlinks but avoids infinite loops @@ -1472,7 +1787,7 @@ where opts } }; - + zip.start_file(zip_path.to_string_lossy().as_ref(), file_opts)?; let data = fs::read(path).context("Failed to read file while zipping")?; zip.write_all(&data)?; @@ -1492,7 +1807,7 @@ where { 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 @@ -1505,14 +1820,17 @@ where } 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) + + anyhow::bail!( + "Package {} not found in workspace node_modules", + package_name + ) } /// Resolve dependencies for regular workspaces (non-pnpm) @@ -1526,14 +1844,14 @@ fn resolve_workspace_dependencies( 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() { @@ -1547,14 +1865,14 @@ fn resolve_workspace_dependencies( } 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")?; - + + 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() { @@ -1562,29 +1880,37 @@ fn resolve_workspace_dependencies( 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)?; + 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)?; + resolve_workspace_dependencies( + node_modules_path, + dep_name, + resolved, + depth + 1, + )?; } } } } - + Ok(()) } - - diff --git a/src/main.rs b/src/main.rs index d212c28..f39ceed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,9 @@ enum Commands { /// Custom name for the executable (optional) #[arg(short, long)] name: Option, + /// Disable compression for faster bundling (useful for testing) + #[arg(long)] + no_compression: bool, }, } @@ -37,8 +40,13 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Bundle { path, output, name } => { - bundler::bundle_project(path, output, name).await?; + Commands::Bundle { + path, + output, + name, + no_compression, + } => { + bundler::bundle_project(path, output, name, no_compression).await?; } } diff --git a/src/node_downloader.rs b/src/node_downloader.rs index 071d88b..04e2367 100644 --- a/src/node_downloader.rs +++ b/src/node_downloader.rs @@ -1,12 +1,12 @@ use crate::platform::Platform; use anyhow::{Context, Result}; use futures_util::StreamExt; +use lazy_static::lazy_static; use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use tokio::fs; use tokio::io::AsyncWriteExt; -use std::path::{Path, PathBuf}; -use lazy_static::lazy_static; lazy_static! { static ref NODE_VERSION_CACHE: Mutex> = Mutex::new(HashMap::new()); @@ -19,15 +19,6 @@ pub struct NodeDownloader { } impl NodeDownloader { - #[allow(dead_code)] - pub fn new(cache_dir: PathBuf, node_version: String) -> Self { - Self { - platform: Platform::current(), - cache_dir, - node_version, - } - } - pub fn new_with_persistent_cache(node_version: String) -> Result { let cache_dir = Self::get_persistent_cache_dir()?; Ok(Self { @@ -57,28 +48,32 @@ impl NodeDownloader { pub async fn ensure_node_binary(&self) -> Result { // Create cache key for this version and platform let cache_key = format!("{}:{}", self.node_version, self.platform); - + // Check in-memory cache first { - let cache = NODE_VERSION_CACHE.lock().map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?; + let cache = NODE_VERSION_CACHE + .lock() + .map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?; if let Some(cached_path) = cache.get(&cache_key) { if cached_path.exists() { return Ok(cached_path.clone()); } } } - + // Check disk cache - let node_dir = self.cache_dir + let node_dir = self + .cache_dir .join("node") .join(&self.node_version) .join(self.platform.to_string()); - + let node_executable = node_dir.join(self.platform.node_executable_path()); if node_executable.exists() { // Update in-memory cache - let mut cache = NODE_VERSION_CACHE.lock() + let mut cache = NODE_VERSION_CACHE + .lock() .map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?; cache.insert(cache_key, node_executable.clone()); return Ok(node_executable); @@ -159,14 +154,15 @@ impl NodeDownloader { fs::remove_file(&archive_path) .await .context("Failed to remove archive file")?; - + // Update in-memory cache with the path to the node executable let node_executable_path = target_dir.join(self.platform.node_executable_path()); - let mut cache = NODE_VERSION_CACHE.lock() + let mut cache = NODE_VERSION_CACHE + .lock() .map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?; cache.insert( format!("{}:{}", self.node_version, self.platform), - node_executable_path.clone() + node_executable_path.clone(), ); Ok(()) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index b106877..32093f2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,9 +1,11 @@ +#![allow(dead_code)] + +use anyhow::Result; +use std::fs; +use std::path::{Path, PathBuf}; 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)] @@ -47,12 +49,14 @@ impl TestProject { } pub fn with_dependency(mut self, name: &str, version: &str) -> Self { - self.dependencies.push((name.to_string(), version.to_string())); + 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.dev_dependencies + .push((name.to_string(), version.to_string())); self } @@ -67,7 +71,9 @@ impl TestProject { } pub fn typescript(mut self, out_dir: &str) -> Self { - self.project_type = ProjectType::TypeScript { out_dir: out_dir.to_string() }; + self.project_type = ProjectType::TypeScript { + out_dir: out_dir.to_string(), + }; self } @@ -264,7 +270,8 @@ process.exit(0);"#; fs::write(self.project_path.join("package.json"), package_json)?; - let tsconfig_json = format!(r#"{{ + let tsconfig_json = format!( + r#"{{ "compilerOptions": {{ "target": "ES2020", "module": "commonjs", @@ -272,7 +279,8 @@ process.exit(0);"#; "rootDir": "./src", "strict": true }} -}}"#); +}}"# + ); fs::write(self.project_path.join("tsconfig.json"), tsconfig_json)?; @@ -301,7 +309,10 @@ try { console.log("Marker file not found"); }"#; - fs::write(self.project_path.join(out_dir).join("index.js"), compiled_index_js)?; + 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); @@ -316,7 +327,8 @@ try { fs::create_dir_all(&self.project_path)?; // Create workspace root package.json - let workspace_package_json = format!(r#"{{ + let workspace_package_json = format!( + r#"{{ "name": "test-workspace", "version": "1.0.0", "private": true, @@ -326,7 +338,10 @@ try { "dependencies": {{ {} }} -}}"#, config.name, self.format_dependencies(&config.dependencies)); +}}"#, + config.name.replace("/", "-"), // Replace slashes to make valid package name + self.format_dependencies(&config.dependencies) + ); fs::write(workspace_root.join("package.json"), workspace_package_json)?; @@ -374,21 +389,27 @@ process.exit(0);"#; fs::create_dir_all(&self.project_path)?; // Create pnpm-workspace.yaml - let pnpm_workspace = format!(r#"packages: + let pnpm_workspace = format!( + r#"packages: - '{}' -"#, config.name); +"#, + config.name + ); fs::write(workspace_root.join("pnpm-workspace.yaml"), pnpm_workspace)?; // Create workspace root package.json - let workspace_package_json = format!(r#"{{ + let workspace_package_json = format!( + r#"{{ "name": "test-pnpm-workspace", "version": "1.0.0", "private": true, "dependencies": {{ {} }} -}}"#, self.format_dependencies(&config.dependencies)); +}}"#, + self.format_dependencies(&config.dependencies) + ); fs::write(workspace_root.join("package.json"), workspace_package_json)?; @@ -434,7 +455,8 @@ process.exit(0);"#; let deps = self.format_dependencies(&config.dependencies); let dev_deps = self.format_dependencies(&config.dev_dependencies); - let package_json = format!(r#"{{ + let package_json = format!( + r#"{{ "name": "{}", "version": "1.0.0", "main": "index.js", @@ -443,8 +465,16 @@ process.exit(0);"#; }}{}{} }}"#, 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) } + 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) @@ -466,13 +496,11 @@ impl BundlerTestHelper { 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()?; + let output = Command::new("cargo").args(["build"]).output()?; if !output.status.success() { anyhow::bail!( @@ -490,9 +518,19 @@ impl BundlerTestHelper { project_path: &Path, output_dir: &Path, custom_name: Option<&str>, + ) -> Result { + Self::bundle_project_with_compression(project_path, output_dir, custom_name, true) + } + + /// Bundle a project with compression control and return the path to the created executable + pub fn bundle_project_with_compression( + project_path: &Path, + output_dir: &Path, + custom_name: Option<&str>, + enable_compression: bool, ) -> 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); @@ -501,6 +539,10 @@ impl BundlerTestHelper { cmd.args(["--name", name]); } + if !enable_compression { + cmd.arg("--no-compression"); + } + let bundle_output = Self::run_with_timeout(&mut cmd, Duration::from_secs(300))?; if !bundle_output.status.success() { @@ -526,14 +568,17 @@ impl BundlerTestHelper { } 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()); + anyhow::bail!( + "Executable was not created at {}", + executable_path.display() + ); } Ok(executable_path) @@ -557,7 +602,7 @@ impl BundlerTestHelper { let mut cmd = Command::new(executable_path); cmd.args(args); - + for (key, value) in env_vars { cmd.env(key, value); } @@ -605,6 +650,63 @@ impl BundlerTestHelper { } } +/// Test cache management utilities +pub struct TestCacheManager; + +impl TestCacheManager { + /// Clear application cache for testing + pub fn clear_application_cache() -> Result<()> { + // Determine cache directory based on platform + let cache_dir = if cfg!(windows) { + if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { + std::path::PathBuf::from(local_app_data).join("banderole") + } else { + return Ok(()); // Can't determine cache dir, skip cleanup + } + } else { + if let Some(xdg_cache) = std::env::var_os("XDG_CACHE_HOME") { + std::path::PathBuf::from(xdg_cache).join("banderole") + } else if let Some(home) = std::env::var_os("HOME") { + std::path::PathBuf::from(home) + .join(".cache") + .join("banderole") + } else { + std::path::PathBuf::from("/tmp").join("banderole-cache") + } + }; + + if cache_dir.exists() { + println!("Clearing application cache at: {}", cache_dir.display()); + + // Only remove application cache directories, not the Node.js cache + if let Ok(entries) = std::fs::read_dir(&cache_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Only remove directories that look like UUIDs (application cache) + // Keep "node" directory (Node.js binaries cache) + if dir_name != "node" && dir_name.len() > 10 { + if let Err(e) = std::fs::remove_dir_all(&path) { + println!( + "Warning: Failed to remove cache directory {}: {}", + path.display(), + e + ); + } else { + println!("Removed cache directory: {}", path.display()); + } + } + } + } + } + } + + Ok(()) + } +} + /// Assertion helpers for test verification pub struct TestAssertions; @@ -617,7 +719,7 @@ impl TestAssertions { 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); @@ -644,12 +746,9 @@ impl TestAssertions { } /// Assert that dependency tests pass in the bundled executable - pub fn assert_dependency_test_passes( - executable_path: &Path, - test_marker: &str, - ) -> Result<()> { + 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); @@ -672,4 +771,4 @@ impl TestAssertions { Ok(()) } -} \ No newline at end of file +} diff --git a/tests/concurrent_execution_integration_test.rs b/tests/concurrent_execution_integration_test.rs new file mode 100644 index 0000000..ea92415 --- /dev/null +++ b/tests/concurrent_execution_integration_test.rs @@ -0,0 +1,427 @@ +mod common; + +use anyhow::Result; +use common::{BundlerTestHelper, TestCacheManager, TestProject, TestProjectManager}; +use serial_test::serial; +use std::process::Command; +use std::sync::{Arc, Barrier}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Test concurrent execution during first launch +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_concurrent_first_launch() -> Result<()> { + println!("Testing concurrent execution during first launch..."); + + // Create a simple test project + let project = TestProject::new("concurrent-test-app").with_dependency("uuid", "^9.0.1"); + + let manager = TestProjectManager::create(project)?; + manager.install_dependencies()?; + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project_with_compression( + manager.project_path(), + manager.temp_dir(), + Some("concurrent-test"), + false, // No compression for faster testing + )?; + + // Clear any existing cache to ensure we test first launch + TestCacheManager::clear_application_cache()?; + + // Number of concurrent executions to test + const NUM_CONCURRENT: usize = 5; + + // Use a barrier to synchronize the start of all threads + let barrier = Arc::new(Barrier::new(NUM_CONCURRENT)); + let executable_path = Arc::new(executable_path); + + let mut handles = Vec::new(); + let start_time = Instant::now(); + + // Spawn multiple threads that will execute the binary concurrently + for i in 0..NUM_CONCURRENT { + let barrier = Arc::clone(&barrier); + let executable_path = Arc::clone(&executable_path); + + let handle = thread::spawn(move || -> Result<(usize, Duration, String)> { + // Wait for all threads to be ready + barrier.wait(); + + let thread_start = Instant::now(); + + // Execute the binary + let output = Command::new(executable_path.as_ref()) + .env("TEST_VAR", format!("thread_{}", i)) + .args(&[format!("--thread-id={}", i)]) + .output() + .map_err(|e| anyhow::anyhow!("Failed to execute binary: {}", e))?; + + let duration = thread_start.elapsed(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Thread {} failed with exit code {:?}. Stderr: {}", + i, + output.status.code(), + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok((i, duration, stdout)) + }); + + handles.push(handle); + } + + // Wait for all threads to complete and collect results + let mut results = Vec::new(); + for handle in handles { + let result = handle + .join() + .map_err(|e| anyhow::anyhow!("Thread panicked: {:?}", e))??; + results.push(result); + } + + let total_time = start_time.elapsed(); + println!("Total concurrent execution time: {:?}", total_time); + + // Verify all executions succeeded + assert_eq!( + results.len(), + NUM_CONCURRENT, + "Not all threads completed successfully" + ); + + // Verify each execution produced expected output + for (thread_id, duration, stdout) in &results { + println!("Thread {} completed in {:?}", thread_id, duration); + + // Check for expected output + assert!( + stdout.contains("Hello from test project!"), + "Thread {} missing expected greeting in output: {}", + thread_id, + stdout + ); + + assert!( + stdout.contains(&format!("thread_{}", thread_id)), + "Thread {} missing environment variable in output: {}", + thread_id, + stdout + ); + + assert!( + stdout.contains(&format!("--thread-id={}", thread_id)), + "Thread {} missing argument in output: {}", + thread_id, + stdout + ); + } + + // Verify that the execution was properly queued (no thread should have taken too long) + let max_duration = results + .iter() + .map(|(_, duration, _)| *duration) + .max() + .unwrap(); + let min_duration = results + .iter() + .map(|(_, duration, _)| *duration) + .min() + .unwrap(); + + println!("Duration range: {:?} - {:?}", min_duration, max_duration); + + // The difference shouldn't be too extreme if queueing is working properly + // Allow up to 30 seconds difference for extraction + queue processing + assert!( + max_duration - min_duration < Duration::from_secs(30), + "Duration difference too large: {:?}, suggesting queue is not working properly", + max_duration - min_duration + ); + + println!("✅ Concurrent first launch test passed!"); + Ok(()) +} + +/// Test that subsequent executions after cache is populated are fast +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_cached_concurrent_execution() -> Result<()> { + println!("Testing concurrent execution with populated cache..."); + + // Create a simple test project + let project = TestProject::new("cached-concurrent-app").with_dependency("lodash", "^4.17.21"); + + let manager = TestProjectManager::create(project)?; + manager.install_dependencies()?; + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project_with_compression( + manager.project_path(), + manager.temp_dir(), + Some("cached-concurrent-test"), + false, + )?; + + // Clear cache and run once to populate it + TestCacheManager::clear_application_cache()?; + + println!("Populating cache with initial run..."); + let initial_output = Command::new(&executable_path) + .env("TEST_VAR", "initial") + .output()?; + + assert!( + initial_output.status.success(), + "Initial run failed: {}", + String::from_utf8_lossy(&initial_output.stderr) + ); + + // Now test concurrent execution with populated cache + const NUM_CONCURRENT: usize = 8; + let barrier = Arc::new(Barrier::new(NUM_CONCURRENT)); + let executable_path = Arc::new(executable_path); + + let mut handles = Vec::new(); + let start_time = Instant::now(); + + for i in 0..NUM_CONCURRENT { + let barrier = Arc::clone(&barrier); + let executable_path = Arc::clone(&executable_path); + + let handle = thread::spawn(move || -> Result<(usize, Duration)> { + barrier.wait(); + + let thread_start = Instant::now(); + + let output = Command::new(executable_path.as_ref()) + .env("TEST_VAR", format!("cached_{}", i)) + .output() + .map_err(|e| anyhow::anyhow!("Failed to execute binary: {}", e))?; + + let duration = thread_start.elapsed(); + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Cached thread {} failed: {}", + i, + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok((i, duration)) + }); + + handles.push(handle); + } + + let mut results = Vec::new(); + for handle in handles { + let result = handle + .join() + .map_err(|e| anyhow::anyhow!("Thread panicked: {:?}", e))??; + results.push(result); + } + + let total_time = start_time.elapsed(); + println!("Total cached concurrent execution time: {:?}", total_time); + + // Verify all executions succeeded + assert_eq!(results.len(), NUM_CONCURRENT); + + // With cache populated, all executions should be relatively fast + for (thread_id, duration) in &results { + println!("Cached thread {} completed in {:?}", thread_id, duration); + + // Each execution should be fast since cache is populated + assert!( + *duration < Duration::from_secs(10), + "Cached execution took too long: {:?} for thread {}", + duration, + thread_id + ); + } + + println!("✅ Cached concurrent execution test passed!"); + Ok(()) +} + +/// Test queue ordering - verify that processes are executed in the order they were queued +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_queue_ordering() -> Result<()> { + println!("Testing queue ordering..."); + + // Create a test project that takes a bit of time to execute + let project = TestProject::new("queue-order-app"); + let manager = TestProjectManager::create(project)?; + + // Create a custom index.js that logs timing information + let index_js = r#" +const fs = require('fs'); +const path = require('path'); + +// Get thread ID from arguments +const threadId = process.argv.find(arg => arg.startsWith('--thread-id='))?.split('=')[1] || 'unknown'; +const startTime = Date.now(); + +console.log(`Thread ${threadId} started at ${startTime}`); +console.log("Hello from test project!"); +console.log("Node version:", process.version); + +// Simulate some work +const start = Date.now(); +while (Date.now() - start < 100) { + // Busy wait for 100ms to simulate work +} + +console.log(`Thread ${threadId} completed at ${Date.now()}`); +console.log("All tests completed!"); +process.exit(0); +"#; + + std::fs::write(manager.project_path().join("index.js"), index_js)?; + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project_with_compression( + manager.project_path(), + manager.temp_dir(), + Some("queue-order-test"), + false, + )?; + + // Clear cache to ensure we test first launch queueing + TestCacheManager::clear_application_cache()?; + + const NUM_THREADS: usize = 4; + let barrier = Arc::new(Barrier::new(NUM_THREADS)); + let executable_path = Arc::new(executable_path); + + let mut handles = Vec::new(); + + for i in 0..NUM_THREADS { + let barrier = Arc::clone(&barrier); + let executable_path = Arc::clone(&executable_path); + + let handle = thread::spawn(move || -> Result<(usize, String)> { + barrier.wait(); + + // Add a small delay to ensure threads start in order + thread::sleep(Duration::from_millis(i as u64 * 10)); + + let output = Command::new(executable_path.as_ref()) + .args(&[format!("--thread-id={}", i)]) + .output() + .map_err(|e| anyhow::anyhow!("Failed to execute binary: {}", e))?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Queue order thread {} failed: {}", + i, + String::from_utf8_lossy(&output.stderr) + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok((i, stdout)) + }); + + handles.push(handle); + } + + let mut results = Vec::new(); + for handle in handles { + let result = handle + .join() + .map_err(|e| anyhow::anyhow!("Thread panicked: {:?}", e))??; + results.push(result); + } + + // Verify all executions succeeded + assert_eq!(results.len(), NUM_THREADS); + + for (thread_id, stdout) in &results { + println!( + "Queue order thread {} output: {}", + thread_id, + stdout.lines().next().unwrap_or("") + ); + + assert!( + stdout.contains(&format!("Thread {} started", thread_id)), + "Thread {} missing start message", + thread_id + ); + + assert!( + stdout.contains(&format!("Thread {} completed", thread_id)), + "Thread {} missing completion message", + thread_id + ); + } + + println!("✅ Queue ordering test passed!"); + Ok(()) +} + +/// Test recovery from failed extraction +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_extraction_failure_recovery() -> Result<()> { + println!("Testing recovery from extraction failure..."); + + // Create a simple test project + let project = TestProject::new("recovery-test-app"); + let manager = TestProjectManager::create(project)?; + + // Bundle the project + let executable_path = BundlerTestHelper::bundle_project_with_compression( + manager.project_path(), + manager.temp_dir(), + Some("recovery-test"), + false, + )?; + + // Clear cache + TestCacheManager::clear_application_cache()?; + + // Test that after clearing cache, the binary still works + let output = Command::new(&executable_path) + .env("TEST_VAR", "recovery_test") + .output()?; + + assert!( + output.status.success(), + "Recovery test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Hello from test project!"), + "Recovery test missing expected output: {}", + stdout + ); + + println!("✅ Extraction failure recovery test passed!"); + Ok(()) +} + +/// Cleanup function to be called after all tests +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_zzz_cleanup_cache() -> Result<()> { + println!("Cleaning up application cache after all tests..."); + + // This test runs last due to the "zzz" prefix, ensuring cleanup happens after other tests + TestCacheManager::clear_application_cache()?; + + println!("✅ Cache cleanup completed!"); + Ok(()) +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 84cdf10..7685136 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -4,7 +4,7 @@ use std::process::Command; use std::time::Duration; use tempfile::TempDir; -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_bundle_and_run() -> Result<(), Box> { let temp_dir = TempDir::new()?; @@ -95,12 +95,16 @@ process.exit(0);"#; ); } - // Bundle the test app + // Bundle the test app) println!("Bundling test app..."); let mut bundle_cmd = Command::new(&banderole_path); bundle_cmd - .args(["bundle", test_app_path.to_str().unwrap()]) + .args([ + "bundle", + test_app_path.to_str().unwrap(), + "--no-compression", + ]) .current_dir(temp_dir.path()); let bundle_output = bundle_cmd.output()?; @@ -204,7 +208,7 @@ process.exit(0);"#; Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_node_version_detection() { let temp_dir = TempDir::new().unwrap(); @@ -246,7 +250,7 @@ process.exit(0);"#; .unwrap() .join("target/release/banderole"); - // Bundle the test app + // Bundle the test app (keep compression for this test to verify it works) println!("Bundling test app with .nvmrc..."); let mut bundle_cmd = Command::new(&banderole_path); bundle_cmd @@ -343,7 +347,7 @@ process.exit(0);"#; ); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_output_path_collision_handling() -> Result<(), Box> { let temp_dir = TempDir::new()?; @@ -383,12 +387,16 @@ async fn test_output_path_collision_handling() -> Result<(), Box Result<(), Box Result<(), Box> { let temp_dir = TempDir::new()?; @@ -509,12 +517,16 @@ try { ); } - // Bundle the TypeScript project + // Bundle the TypeScript project) println!("Testing TypeScript project bundling..."); let mut bundle_cmd = Command::new(&banderole_path); bundle_cmd - .args(["bundle", test_app_path.to_str().unwrap()]) + .args([ + "bundle", + test_app_path.to_str().unwrap(), + "--no-compression", + ]) .current_dir(temp_dir.path()); let bundle_output = run_with_timeout(&mut bundle_cmd, Duration::from_secs(300))?; @@ -594,7 +606,7 @@ try { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_typescript_project_with_tsconfig_outdir() -> Result<(), Box> { let temp_dir = TempDir::new()?; @@ -653,12 +665,16 @@ try { ); } - // Bundle the project + // Bundle the project) println!("Testing TypeScript project with custom outDir..."); let mut bundle_cmd = Command::new(&banderole_path); bundle_cmd - .args(["bundle", test_app_path.to_str().unwrap()]) + .args([ + "bundle", + test_app_path.to_str().unwrap(), + "--no-compression", + ]) .current_dir(temp_dir.path()); let bundle_output = bundle_cmd.output()?; @@ -730,7 +746,7 @@ try { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_typescript_project_with_extends() -> Result<(), Box> { let temp_dir = TempDir::new()?; @@ -796,12 +812,16 @@ try { ); } - // Bundle the project + // Bundle the project) println!("Testing TypeScript project with extends..."); let mut bundle_cmd = Command::new(&banderole_path); bundle_cmd - .args(["bundle", test_app_path.to_str().unwrap()]) + .args([ + "bundle", + test_app_path.to_str().unwrap(), + "--no-compression", + ]) .current_dir(temp_dir.path()); let bundle_output = bundle_cmd.output()?; @@ -876,7 +896,7 @@ try { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_pnpm_dependencies_bundling() -> Result<(), Box> { let temp_dir = TempDir::new()?; @@ -1005,12 +1025,16 @@ packages: ); } - // Bundle the pnpm project + // Bundle the pnpm project) println!("Testing pnpm dependency bundling..."); let mut bundle_cmd = Command::new(&banderole_path); bundle_cmd - .args(["bundle", test_app_path.to_str().unwrap()]) + .args([ + "bundle", + test_app_path.to_str().unwrap(), + "--no-compression", + ]) .current_dir(temp_dir.path()); let bundle_output = run_with_timeout(&mut bundle_cmd, Duration::from_secs(300))?; @@ -1115,7 +1139,7 @@ packages: Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_bundle_simple_project() { let temp_dir = TempDir::new().unwrap(); @@ -1157,7 +1181,7 @@ async fn test_bundle_simple_project() { assert!(npm_install.status.success(), "npm install failed"); - // Bundle the project (we'll use the CLI instead) + // Bundle the project (we'll use the CLI instead, no compression for speed) let cargo_bin = env!("CARGO_BIN_EXE_banderole"); let bundle_output = Command::new(cargo_bin) .arg("bundle") @@ -1166,6 +1190,7 @@ async fn test_bundle_simple_project() { .arg(temp_dir.path().join("test-bundle")) .arg("--name") .arg("test-bundle") + .arg("--no-compression") .output() .unwrap(); @@ -1183,7 +1208,7 @@ async fn test_bundle_simple_project() { // which is complex in a test environment } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_pnpm_project_bundling() { // This test demonstrates that pnpm projects can be bundled @@ -1228,7 +1253,7 @@ packages: fs::write(project_path.join("pnpm-lock.yaml"), pnpm_lock).unwrap(); - // The bundling should handle the pnpm structure gracefully + // The bundling should handle the pnpm structure gracefully) let cargo_bin = env!("CARGO_BIN_EXE_banderole"); let result = Command::new(cargo_bin) .arg("bundle") @@ -1237,6 +1262,7 @@ packages: .arg(temp_dir.path().join("pnpm-bundle")) .arg("--name") .arg("pnpm-bundle") + .arg("--no-compression") .output() .unwrap(); @@ -1291,3 +1317,17 @@ fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> std::io::Result Result<(), Box> { + println!("Cleaning up application cache after integration tests..."); + + TestCacheManager::clear_application_cache()?; + + println!("✅ Integration cache cleanup completed!"); + Ok(()) +} diff --git a/tests/workspace_integration_test.rs b/tests/workspace_integration_test.rs index 04ebb7c..96a7920 100644 --- a/tests/workspace_integration_test.rs +++ b/tests/workspace_integration_test.rs @@ -1,10 +1,12 @@ mod common; use anyhow::Result; -use common::{BundlerTestHelper, TestAssertions, TestProject, TestProjectManager}; +use common::{ + BundlerTestHelper, TestAssertions, TestCacheManager, TestProject, TestProjectManager, +}; use serial_test::serial; -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_npm_workspace_dependency_bundling() -> Result<()> { println!("Testing npm workspace dependency bundling..."); @@ -31,11 +33,12 @@ async fn test_npm_workspace_dependency_bundling() -> Result<()> { "commander should be installed in workspace root" ); - // Bundle the workspace project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the workspace project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("workspace-test"), + false, )?; // Test the bundled executable @@ -57,7 +60,7 @@ async fn test_npm_workspace_dependency_bundling() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_pnpm_workspace_dependency_bundling() -> Result<()> { println!("Testing pnpm workspace dependency bundling..."); @@ -81,11 +84,12 @@ async fn test_pnpm_workspace_dependency_bundling() -> Result<()> { } } - // Bundle the pnpm workspace project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the pnpm workspace project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("pnpm-workspace-test"), + false, )?; // Test the bundled executable @@ -105,7 +109,7 @@ async fn test_pnpm_workspace_dependency_bundling() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_workspace_with_typescript_project() -> Result<()> { println!("Testing workspace with TypeScript project..."); @@ -122,11 +126,12 @@ async fn test_workspace_with_typescript_project() -> Result<()> { // Install dependencies manager.install_workspace_dependencies()?; - // Bundle the TypeScript workspace project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the TypeScript workspace project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("workspace-ts-test"), + false, )?; // Test the bundled executable @@ -145,7 +150,7 @@ async fn test_workspace_with_typescript_project() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_workspace_nested_dependencies() -> Result<()> { println!("Testing workspace with nested dependencies..."); @@ -190,11 +195,12 @@ process.exit(0);"#; std::fs::write(manager.project_path().join("index.js"), complex_index_js)?; - // Bundle the project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("nested-deps-test"), + false, )?; // Test the bundled executable @@ -207,7 +213,7 @@ process.exit(0);"#; Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_workspace_with_bin_scripts() -> Result<()> { println!("Testing workspace with bin scripts..."); @@ -230,11 +236,12 @@ async fn test_workspace_with_bin_scripts() -> Result<()> { ".bin directory should exist in workspace node_modules" ); - // Bundle the project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("bin-scripts-test"), + false, )?; // Test the bundled executable @@ -253,7 +260,7 @@ async fn test_workspace_with_bin_scripts() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_workspace_project_without_own_node_modules() -> Result<()> { println!("Testing workspace project without its own node_modules..."); @@ -282,11 +289,12 @@ async fn test_workspace_project_without_own_node_modules() -> Result<()> { "minimist should be in workspace node_modules" ); - // Bundle the project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("no-local-deps-test"), + false, )?; // Test the bundled executable @@ -305,7 +313,7 @@ async fn test_workspace_project_without_own_node_modules() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_workspace_with_peer_dependencies() -> Result<()> { println!("Testing workspace with peer dependencies..."); @@ -343,11 +351,12 @@ async fn test_workspace_with_peer_dependencies() -> Result<()> { // Install dependencies manager.install_workspace_dependencies()?; - // Bundle the project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("peer-deps-test"), + false, )?; // Test the bundled executable @@ -366,13 +375,13 @@ async fn test_workspace_with_peer_dependencies() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] 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") + let project = TestProject::new("deep-nested-client") .workspace() .with_dependency("uuid", "^9.0.1") .with_dependency("date-fns", "^2.30.0"); @@ -382,11 +391,12 @@ async fn test_deep_workspace_nesting() -> Result<()> { // Install dependencies in workspace root manager.install_workspace_dependencies()?; - // Bundle the deeply nested project - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the deeply nested project) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("deep-nested-test"), + false, )?; // Test the bundled executable @@ -405,7 +415,7 @@ async fn test_deep_workspace_nesting() -> Result<()> { Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] #[serial] async fn test_workspace_collision_handling() -> Result<()> { println!("Testing workspace collision handling..."); @@ -423,11 +433,12 @@ async fn test_workspace_collision_handling() -> Result<()> { // Install dependencies manager.install_workspace_dependencies()?; - // Bundle the project (should handle collision automatically) - let executable_path = BundlerTestHelper::bundle_project( + // Bundle the project (should handle collision automatically, no compression for speed) + let executable_path = BundlerTestHelper::bundle_project_with_compression( manager.project_path(), manager.temp_dir(), Some("collision-test"), + false, )?; // The executable should exist with collision avoidance @@ -451,3 +462,14 @@ async fn test_workspace_collision_handling() -> Result<()> { println!("✅ workspace collision handling test passed!"); Ok(()) } +/// Cleanup function to be called after all workspace tests +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_zzz_cleanup_workspace_cache() -> Result<()> { + println!("Cleaning up application cache after workspace tests..."); + + TestCacheManager::clear_application_cache()?; + + println!("✅ Workspace cache cleanup completed!"); + Ok(()) +}