feat: better handling of concurrent execution on first launch

This commit is contained in:
zhom
2025-07-26 12:37:25 +04:00
parent 0fa6c352e7
commit 06f8ed1858
9 changed files with 1311 additions and 311 deletions
Generated
+80 -2
View File
@@ -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",
]
+4
View File
@@ -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
+527 -201
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -29,6 +29,9 @@ enum Commands {
/// Custom name for the executable (optional)
#[arg(short, long)]
name: Option<String>,
/// 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?;
}
}
+16 -20
View File
@@ -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<HashMap<String, PathBuf>> = 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<Self> {
let cache_dir = Self::get_persistent_cache_dir()?;
Ok(Self {
@@ -57,28 +48,32 @@ impl NodeDownloader {
pub async fn ensure_node_binary(&self) -> Result<PathBuf> {
// 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(())
+132 -33
View File
@@ -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<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()?;
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<PathBuf> {
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<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);
@@ -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(())
}
}
}
@@ -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(())
}
+64 -24
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
@@ -383,12 +387,16 @@ async fn test_output_path_collision_handling() -> Result<(), Box<dyn std::error:
);
}
// Bundle the test app
// Bundle the test app)
println!("Testing output path collision handling...");
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))?;
@@ -438,7 +446,7 @@ async fn test_output_path_collision_handling() -> Result<(), Box<dyn std::error:
Ok(())
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_typescript_project_with_dist() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<std
}
}
}
mod common;
use common::TestCacheManager;
/// Cleanup function to be called after all integration tests
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_zzz_cleanup_integration_cache() -> Result<(), Box<dyn std::error::Error>> {
println!("Cleaning up application cache after integration tests...");
TestCacheManager::clear_application_cache()?;
println!("✅ Integration cache cleanup completed!");
Ok(())
}
+51 -29
View File
@@ -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(())
}