mirror of
https://github.com/zhom/banderole.git
synced 2026-04-28 23:06:08 +02:00
feat: support pnpm workspaces
This commit is contained in:
@@ -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
|
||||
|
||||
+471
-52
@@ -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::<Value>(&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<W>(
|
||||
|
||||
|
||||
/// Bundle node_modules with comprehensive dependency resolution
|
||||
fn bundle_node_modules_comprehensive<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
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::<Value>(&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<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
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::<Value>(&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<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
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::<Value>(&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<W>(
|
||||
zip: &mut ZipWriter<W>,
|
||||
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<String>,
|
||||
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::<Value>(&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(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub has_node_version: Option<String>,
|
||||
}
|
||||
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
impl TestProjectManager {
|
||||
/// Create a new test project in a temporary directory
|
||||
pub fn create(config: TestProject) -> Result<Self> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<std::process::Output> {
|
||||
// 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<std::process::Output> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user