diff --git a/README.md b/README.md index 7c6ddb9..ca5efb3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,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) ## License diff --git a/simple-app/index.js b/simple-app/index.js deleted file mode 100644 index 69ae7c8..0000000 --- a/simple-app/index.js +++ /dev/null @@ -1,3 +0,0 @@ -console.log("Hello from simple-app!"); -console.log("Node.js version:", process.version); -console.log("Arguments:", process.argv.slice(2)); \ No newline at end of file diff --git a/simple-app/package.json b/simple-app/package.json deleted file mode 100644 index 2fcb1d1..0000000 --- a/simple-app/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "simple-app", - "version": "1.0.0", - "main": "index.js" -} \ No newline at end of file diff --git a/src/bundler_simple.rs b/src/bundler.rs similarity index 59% rename from src/bundler_simple.rs rename to src/bundler.rs index c4fcdce..20db488 100644 --- a/src/bundler_simple.rs +++ b/src/bundler.rs @@ -25,32 +25,34 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option, let pkg_json = project_path.join("package.json"); anyhow::ensure!(pkg_json.exists(), "package.json not found in {}", project_path.display()); - // 2. Read `package.json` for name/version (best-effort – fall back if absent). - let (app_name, app_version) = { - let content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?; - let value: Value = serde_json::from_str(&content).context("Failed to parse package.json")?; - ( - value["name"].as_str().unwrap_or("app").to_string(), - value["version"].as_str().unwrap_or("0.0.0").to_string(), - ) - }; + // 2. Read `package.json` for name/version and detect project structure + let package_content = fs::read_to_string(&pkg_json).context("Failed to read package.json")?; + let package_value: Value = serde_json::from_str(&package_content).context("Failed to parse package.json")?; + + let (app_name, app_version) = ( + package_value["name"].as_str().unwrap_or("app").to_string(), + package_value["version"].as_str().unwrap_or("0.0.0").to_string(), + ); - // 3. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1). + // 3. Determine the correct source directory to bundle + let source_dir = determine_source_directory(&project_path, &package_value)?; + + // 4. Determine Node version (via .nvmrc / .node-version or default to LTS 22.17.1). let node_version = detect_node_version(&project_path).unwrap_or_else(|_| "22.17.1".into()); println!( "Bundling {app_name} v{app_version} using Node.js v{node_version} for {plat}", plat = Platform::current() ); + + if source_dir != project_path { + println!("Using source directory: {}", source_dir.display()); + } - // 4. Resolve output path. - let output_path = output_path.unwrap_or_else(|| { - let ext = if Platform::current().is_windows() { ".exe" } else { "" }; - let base_name = custom_name.as_ref().unwrap_or(&app_name); - PathBuf::from(format!("{base_name}{ext}")) - }); + // 5. Resolve output path with collision handling + let output_path = resolve_output_path(output_path, &app_name, custom_name.as_deref())?; - // 5. Ensure portable Node binary is available. + // 6. Ensure portable Node binary is available. let node_downloader = NodeDownloader::new_with_persistent_cache(node_version.clone())?; let node_executable = node_downloader.ensure_node_binary().await?; let node_root = node_executable @@ -59,21 +61,45 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option, .parent() .unwrap_or_else(|| panic!("Unexpected node layout for {}", node_executable.display())); - // 6. Create an in-memory zip archive containing `/app` and `/node` directories. + // 7. Create an in-memory zip archive containing `/app` and `/node` directories. let mut zip_data: Vec = Vec::new(); { let mut zip = ZipWriter::new(std::io::Cursor::new(&mut zip_data)); let opts: zip::write::FileOptions<'static, ()> = zip::write::FileOptions::default() .compression_method(zip::CompressionMethod::Stored); - // Copy project directory. - add_dir_to_zip(&mut zip, &project_path, Path::new("app"), opts)?; + // Copy the determined source directory + add_dir_to_zip(&mut zip, &source_dir, Path::new("app"), opts)?; + + // If we're using a subdirectory, also copy the root package.json with adjusted paths + if source_dir != project_path { + let root_package_json = project_path.join("package.json"); + if root_package_json.exists() { + zip.start_file("app/package.json", opts)?; + + // Read and modify package.json to adjust the main path + let content = fs::read_to_string(&root_package_json).context("Failed to read root package.json")?; + let mut package_value: Value = serde_json::from_str(&content).context("Failed to parse root package.json")?; + + // Adjust the main field if it points to the source directory + if let Some(main) = package_value["main"].as_str() { + let main_path = project_path.join(main); + if let Ok(relative_to_source) = main_path.strip_prefix(&source_dir) { + package_value["main"] = Value::String(relative_to_source.to_string_lossy().to_string()); + } + } + + let modified_content = serde_json::to_string_pretty(&package_value) + .context("Failed to serialize modified package.json")?; + zip.write_all(modified_content.as_bytes())?; + } + } // Copy Node runtime directory. add_dir_to_zip(&mut zip, node_root, Path::new("node"), opts)?; zip.finish()?; } - // 7. Build self-extracting launcher using a more reliable approach. + // 8. Build self-extracting launcher using a more reliable approach. create_self_extracting_executable(&output_path, zip_data, &app_name)?; println!("Bundle created at {}", output_path.display()); @@ -96,6 +122,150 @@ fn normalise_node_version(raw: &str) -> String { raw.trim_start_matches('v').to_owned() } +/// Determine the correct source directory to bundle for the project. +/// This handles TypeScript projects and other build configurations. +fn determine_source_directory(project_path: &Path, package_json: &Value) -> Result { + // Check if there's a specific main entry point that indicates a built project + if let Some(main) = package_json["main"].as_str() { + let main_path = project_path.join(main); + if let Some(parent) = main_path.parent() { + // If main points to a file in a subdirectory like dist/index.js or build/index.js + let parent_name = parent.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + if ["dist", "build", "lib", "out"].contains(&parent_name) && parent.exists() { + return Ok(parent.to_path_buf()); + } + } + } + + // Check for TypeScript configuration + let tsconfig_path = project_path.join("tsconfig.json"); + if tsconfig_path.exists() { + if let Ok(tsconfig) = read_tsconfig(&tsconfig_path) { + if let Some(out_dir) = tsconfig["compilerOptions"]["outDir"].as_str() { + let out_path = project_path.join(out_dir); + if out_path.exists() { + return Ok(out_path); + } + } + } + } + + // Check for common build output directories + for dir_name in ["dist", "build", "lib", "out"] { + let dir_path = project_path.join(dir_name); + if dir_path.exists() && dir_path.is_dir() { + // Verify it contains JavaScript files or a package.json + if contains_js_files(&dir_path) || dir_path.join("package.json").exists() { + return Ok(dir_path); + } + } + } + + // Default to the project root + Ok(project_path.to_path_buf()) +} + +/// Read and parse tsconfig.json, handling extends configuration +fn read_tsconfig(tsconfig_path: &Path) -> Result { + let content = fs::read_to_string(tsconfig_path) + .context("Failed to read tsconfig.json")?; + + // Remove comments for JSON parsing (simple approach) + let cleaned_content = content + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + let mut config: Value = serde_json::from_str(&cleaned_content) + .context("Failed to parse tsconfig.json")?; + + // Handle extends configuration + if let Some(extends) = config["extends"].as_str() { + let base_path = if extends.starts_with('.') { + tsconfig_path.parent().unwrap().join(extends) + } else { + // Could be a package reference, but for now we'll skip + return Ok(config); + }; + + // Add .json extension if not present + let base_path = if base_path.extension().is_none() { + base_path.with_extension("json") + } else { + base_path + }; + + if base_path.exists() { + if let Ok(base_config) = read_tsconfig(&base_path) { + // Merge base config with current config (simple merge) + if let (Some(base_obj), Some(current_obj)) = (base_config.as_object(), config.as_object()) { + let mut merged = base_obj.clone(); + for (key, value) in current_obj { + merged.insert(key.clone(), value.clone()); + } + config = Value::Object(merged); + } + } + } + } + + Ok(config) +} + +/// Check if a directory contains JavaScript files +fn contains_js_files(dir: &Path) -> bool { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() { + if name.ends_with(".js") || name.ends_with(".mjs") || name.ends_with(".cjs") { + return true; + } + } + } + } + false +} + +/// Resolve the output path, handling naming conflicts +fn resolve_output_path( + output_path: Option, + app_name: &str, + custom_name: Option<&str>, +) -> Result { + if let Some(path) = output_path { + // If explicit output path is provided, use it as-is + return Ok(path); + } + + let ext = if Platform::current().is_windows() { ".exe" } else { "" }; + let base_name = custom_name.unwrap_or(app_name); + let mut output_path = PathBuf::from(format!("{base_name}{ext}")); + + // Check for conflicts and resolve them + let mut counter = 1; + while output_path.exists() { + if output_path.is_dir() { + // If it's a directory, append a suffix + output_path = PathBuf::from(format!("{base_name}-bundle{ext}")); + if !output_path.exists() { + break; + } + } + + // If it still exists (file or another directory), add a counter + if output_path.exists() { + output_path = PathBuf::from(format!("{base_name}-bundle-{counter}{ext}")); + counter += 1; + } + } + + Ok(output_path) +} + // ──────────────────────────────────────────────────────────────────────────── // Self-extracting executable generation using a more reliable approach // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/main.rs b/src/main.rs index c88c2a2..d212c28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -mod bundler_simple; +mod bundler; mod node_downloader; mod platform; @@ -38,7 +38,7 @@ async fn main() -> anyhow::Result<()> { match cli.command { Commands::Bundle { path, output, name } => { - bundler_simple::bundle_project(path, output, name).await?; + bundler::bundle_project(path, output, name).await?; } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 4a1bc1f..bc59d4d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -97,7 +97,7 @@ process.exit(0);"#; .args(["bundle", test_app_path.to_str().unwrap()]) .current_dir(temp_dir.path()); - let bundle_output = run_with_timeout(&mut bundle_cmd, Duration::from_secs(300))?; + let bundle_output = bundle_cmd.output()?; if !bundle_output.status.success() { println!( @@ -341,6 +341,531 @@ process.exit(0);"#; ); } +#[tokio::test] +async fn test_output_path_collision_handling() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let test_app_path = temp_dir.path().join("collision-test-app"); + + // Create a simple test app + std::fs::create_dir_all(&test_app_path)?; + + let package_json = r#"{ + "name": "collision-test-app", + "version": "1.0.0", + "main": "index.js" +}"#; + + let index_js = r#"console.log("Hello from collision test app!");"#; + + std::fs::write(test_app_path.join("package.json"), package_json)?; + std::fs::write(test_app_path.join("index.js"), index_js)?; + + // Create a directory with the same name as the app to cause collision + std::fs::create_dir_all(temp_dir.path().join("collision-test-app"))?; + + // Build banderole + let target_dir = std::env::current_dir()?.join("target"); + let banderole_path = target_dir.join("debug/banderole"); + + if !banderole_path.exists() { + let output = Command::new("cargo") + .args(["build"]) + .output() + .expect("Failed to build banderole"); + + assert!( + output.status.success(), + "Failed to build banderole: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // 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()]) + .current_dir(temp_dir.path()); + + let bundle_output = run_with_timeout(&mut bundle_cmd, Duration::from_secs(300))?; + + if !bundle_output.status.success() { + println!( + "Bundle stdout: {}", + String::from_utf8_lossy(&bundle_output.stdout) + ); + println!( + "Bundle stderr: {}", + String::from_utf8_lossy(&bundle_output.stderr) + ); + return Err("Bundle command failed".into()); + } + + // Verify that a bundle was created with collision-avoided name + let expected_executable = temp_dir.path().join(if cfg!(windows) { + "collision-test-app-bundle.exe" + } else { + "collision-test-app-bundle" + }); + + assert!( + expected_executable.exists(), + "Executable was not created with collision-avoided name: {}. Directory contents: {:?}", + expected_executable.display(), + std::fs::read_dir(temp_dir.path()) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name()) + .collect::>() + ); + + // Test that the executable works + let output = Command::new(&expected_executable) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + println!("Collision test output: {}", stdout); + + assert!(output.status.success(), "Collision test executable failed"); + assert!( + stdout.contains("Hello from collision test app!"), + "Expected greeting not found in collision test output" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_typescript_project_with_dist() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let test_app_path = temp_dir.path().join("ts-test-app"); + + // Create a TypeScript project structure + std::fs::create_dir_all(&test_app_path)?; + std::fs::create_dir_all(test_app_path.join("dist"))?; + + let package_json = r#"{ + "name": "ts-test-app", + "version": "1.0.0", + "main": "dist/index.js", + "scripts": { + "build": "tsc" + } +}"#; + + let tsconfig_json = r#"{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "./src", + "strict": true + } +}"#; + + let src_index_ts = r#"console.log("Hello from TypeScript app!"); +console.log("Node version:", process.version); +console.log("This should come from the dist directory");"#; + + let dist_index_js = r#"console.log("Hello from TypeScript app!"); +console.log("Node version:", process.version); +console.log("This should come from the dist directory"); +try { + const marker = require('./marker.js'); + console.log("Marker file found:", marker.source); +} catch (e) { + console.log("Marker file not found"); +}"#; + + // Create a distinctive file in dist to verify it was used as source + let dist_marker = r#"module.exports = { source: "dist" };"#; + + // Write project files + std::fs::write(test_app_path.join("package.json"), package_json)?; + std::fs::write(test_app_path.join("tsconfig.json"), tsconfig_json)?; + std::fs::create_dir_all(test_app_path.join("src"))?; + std::fs::write(test_app_path.join("src/index.ts"), src_index_ts)?; + std::fs::write(test_app_path.join("dist/index.js"), dist_index_js)?; + std::fs::write(test_app_path.join("dist/marker.js"), dist_marker)?; + + // Build banderole + let target_dir = std::env::current_dir()?.join("target"); + let banderole_path = target_dir.join("debug/banderole"); + + if !banderole_path.exists() { + let output = Command::new("cargo") + .args(["build"]) + .output() + .expect("Failed to build banderole"); + + assert!( + output.status.success(), + "Failed to build banderole: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // 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()]) + .current_dir(temp_dir.path()); + + let bundle_output = run_with_timeout(&mut bundle_cmd, Duration::from_secs(300))?; + + if !bundle_output.status.success() { + println!( + "Bundle stdout: {}", + String::from_utf8_lossy(&bundle_output.stdout) + ); + println!( + "Bundle stderr: {}", + String::from_utf8_lossy(&bundle_output.stderr) + ); + return Err("TypeScript bundle command failed".into()); + } + + // We'll verify that the dist directory was used by running the executable + // and checking that it includes our marker file + + // Find the created executable (may have collision avoidance suffix) + let mut executable_path = temp_dir.path().join(if cfg!(windows) { + "ts-test-app.exe" + } else { + "ts-test-app" + }); + + // Check if collision avoidance was used (need to check if it's a file, not just exists) + if !executable_path.exists() || !executable_path.is_file() { + executable_path = temp_dir.path().join(if cfg!(windows) { + "ts-test-app-bundle.exe" + } else { + "ts-test-app-bundle" + }); + } + + + + assert!( + executable_path.exists(), + "TypeScript executable was not created: {}. Found files: {:?}", + executable_path.display(), + std::fs::read_dir(temp_dir.path()).unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name()) + .collect::>() + ); + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::metadata(&executable_path)?.permissions(); + let mut perms = perms.clone(); + perms.set_mode(0o755); + std::fs::set_permissions(&executable_path, perms)?; + } + + // Test the executable + let output = Command::new(&executable_path) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + println!("TypeScript test output: {}", stdout); + + assert!(output.status.success(), "TypeScript executable failed"); + assert!( + stdout.contains("Hello from TypeScript app!"), + "Expected greeting not found in TypeScript output" + ); + assert!( + stdout.contains("This should come from the dist directory"), + "Should be running from dist directory" + ); + assert!( + stdout.contains("Marker file found: dist"), + "Should have included marker file from dist directory, indicating dist was used as source" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_typescript_project_with_tsconfig_outdir() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let test_app_path = temp_dir.path().join("ts-outdir-test"); + + // Create a TypeScript project with custom outDir + std::fs::create_dir_all(&test_app_path)?; + std::fs::create_dir_all(test_app_path.join("build"))?; + + let package_json = r#"{ + "name": "ts-outdir-test", + "version": "1.0.0", + "main": "index.js" +}"#; + + let tsconfig_json = r#"{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./build", + "rootDir": "./src" + } +}"#; + + let build_index_js = r#"console.log("Hello from custom outDir!"); +console.log("This comes from the build directory"); +try { + const marker = require('./marker.js'); + console.log("Marker file found:", marker.source); +} catch (e) { + console.log("Marker file not found"); +}"#; + + let build_marker = r#"module.exports = { source: "build" };"#; + + // Write project files + std::fs::write(test_app_path.join("package.json"), package_json)?; + std::fs::write(test_app_path.join("tsconfig.json"), tsconfig_json)?; + std::fs::write(test_app_path.join("build/index.js"), build_index_js)?; + std::fs::write(test_app_path.join("build/marker.js"), build_marker)?; + + // Build banderole + let target_dir = std::env::current_dir()?.join("target"); + let banderole_path = target_dir.join("debug/banderole"); + + if !banderole_path.exists() { + let output = Command::new("cargo") + .args(["build"]) + .output() + .expect("Failed to build banderole"); + + assert!( + output.status.success(), + "Failed to build banderole: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // 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()]) + .current_dir(temp_dir.path()); + + let bundle_output = bundle_cmd.output()?; + + if !bundle_output.status.success() { + println!( + "Bundle stdout: {}", + String::from_utf8_lossy(&bundle_output.stdout) + ); + println!( + "Bundle stderr: {}", + String::from_utf8_lossy(&bundle_output.stderr) + ); + return Err("Custom outDir bundle command failed".into()); + } + + // We'll verify that the build directory was used by checking the marker file + + // Find the created executable (may have collision avoidance suffix) + let mut executable_path = temp_dir.path().join(if cfg!(windows) { + "ts-outdir-test.exe" + } else { + "ts-outdir-test" + }); + + // Check if collision avoidance was used (need to check if it's a file, not just exists) + if !executable_path.exists() || !executable_path.is_file() { + executable_path = temp_dir.path().join(if cfg!(windows) { + "ts-outdir-test-bundle.exe" + } else { + "ts-outdir-test-bundle" + }); + } + + assert!(executable_path.exists(), "Custom outDir executable was not created"); + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::metadata(&executable_path)?.permissions(); + let mut perms = perms.clone(); + perms.set_mode(0o755); + std::fs::set_permissions(&executable_path, perms)?; + } + + let output = Command::new(&executable_path) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + println!("Custom outDir test output: {}", stdout); + + assert!(output.status.success(), "Custom outDir executable failed"); + assert!( + stdout.contains("Hello from custom outDir!"), + "Expected greeting not found" + ); + assert!( + stdout.contains("This comes from the build directory"), + "Should be running from build directory" + ); + assert!( + stdout.contains("Marker file found: build"), + "Should have included marker file from build directory, indicating build was used as source" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_typescript_project_with_extends() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let test_app_path = temp_dir.path().join("ts-extends-test"); + + // Create a TypeScript project with extends configuration + std::fs::create_dir_all(&test_app_path)?; + std::fs::create_dir_all(test_app_path.join("lib"))?; + + let package_json = r#"{ + "name": "ts-extends-test", + "version": "1.0.0", + "main": "index.js" +}"#; + + let base_tsconfig = r#"{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./lib" + } +}"#; + + let tsconfig_json = r#"{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "strict": true + } +}"#; + + let lib_index_js = r#"console.log("Hello from extended tsconfig!"); +console.log("This comes from the lib directory via extends"); +try { + const marker = require('./marker.js'); + console.log("Marker file found:", marker.source); +} catch (e) { + console.log("Marker file not found"); +}"#; + + let lib_marker = r#"module.exports = { source: "lib" };"#; + + // Write project files + std::fs::write(test_app_path.join("package.json"), package_json)?; + std::fs::write(test_app_path.join("tsconfig.base.json"), base_tsconfig)?; + std::fs::write(test_app_path.join("tsconfig.json"), tsconfig_json)?; + std::fs::write(test_app_path.join("lib/index.js"), lib_index_js)?; + std::fs::write(test_app_path.join("lib/marker.js"), lib_marker)?; + + // Build banderole + let target_dir = std::env::current_dir()?.join("target"); + let banderole_path = target_dir.join("debug/banderole"); + + if !banderole_path.exists() { + let output = Command::new("cargo") + .args(["build"]) + .output() + .expect("Failed to build banderole"); + + assert!( + output.status.success(), + "Failed to build banderole: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // 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()]) + .current_dir(temp_dir.path()); + + let bundle_output = bundle_cmd.output()?; + + if !bundle_output.status.success() { + println!( + "Bundle stdout: {}", + String::from_utf8_lossy(&bundle_output.stdout) + ); + println!( + "Bundle stderr: {}", + String::from_utf8_lossy(&bundle_output.stderr) + ); + return Err("Extends tsconfig bundle command failed".into()); + } + + // We'll verify that the lib directory was used by checking the marker file + + // Find the created executable (may have collision avoidance suffix) + let mut executable_path = temp_dir.path().join(if cfg!(windows) { + "ts-extends-test.exe" + } else { + "ts-extends-test" + }); + + // Check if collision avoidance was used (need to check if it's a file, not just exists) + if !executable_path.exists() || !executable_path.is_file() { + executable_path = temp_dir.path().join(if cfg!(windows) { + "ts-extends-test-bundle.exe" + } else { + "ts-extends-test-bundle" + }); + } + + assert!(executable_path.exists(), "Extends tsconfig executable was not created"); + + // Make executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::metadata(&executable_path)?.permissions(); + let mut perms = perms.clone(); + perms.set_mode(0o755); + std::fs::set_permissions(&executable_path, perms)?; + } + + let output = Command::new(&executable_path) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + println!("Extends tsconfig test output: {}", stdout); + + assert!(output.status.success(), "Extends tsconfig executable failed"); + assert!( + stdout.contains("Hello from extended tsconfig!"), + "Expected greeting not found" + ); + assert!( + stdout.contains("This comes from the lib directory via extends"), + "Should be running from lib directory" + ); + assert!( + stdout.contains("Marker file found: lib"), + "Should have included marker file from lib directory, indicating lib was used as source" + ); + + Ok(()) +} + fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> std::io::Result { use std::sync::mpsc; use std::thread;