mirror of
https://github.com/zhom/banderole.git
synced 2026-06-06 06:23:53 +02:00
feat: typescript support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
console.log("Hello from simple-app!");
|
||||
console.log("Node.js version:", process.version);
|
||||
console.log("Arguments:", process.argv.slice(2));
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "simple-app",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
@@ -25,32 +25,34 @@ pub async fn bundle_project(project_path: PathBuf, output_path: Option<PathBuf>,
|
||||
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<PathBuf>,
|
||||
.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<u8> = 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<PathBuf> {
|
||||
// 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<Value> {
|
||||
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::<Vec<_>>()
|
||||
.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<PathBuf>,
|
||||
app_name: &str,
|
||||
custom_name: Option<&str>,
|
||||
) -> Result<PathBuf> {
|
||||
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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
+2
-2
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+526
-1
@@ -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<dyn std::error::Error>> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 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<dyn std::error::Error>> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<std::process::Output> {
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
Reference in New Issue
Block a user