// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. use std::{env, fs, thread, time}; use std::collections::HashMap; use std::fmt::{Debug, Formatter}; use std::fs::{File, OpenOptions}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::sync::Once; use anyhow::{bail, Context, Result}; use log::debug; use serde::{Deserialize, Serialize}; use tempfile::{Builder, TempDir}; use xplat_launcher::{DEBUG_MODE_ENV_VAR, PathExt}; static INIT: Once = Once::new(); static mut SHARED: Option = None; #[derive(Clone)] pub enum LauncherLocation { Standard, RemoteDev } pub struct TestEnvironment<'a> { pub dist_root: PathBuf, pub project_dir: PathBuf, launcher_path: PathBuf, shared_env: &'a TestEnvironmentShared, test_root_dir: TempDir, to_delete: Vec } impl<'a> TestEnvironment<'a> { pub fn create_jbr_link(&self, link_name: &str) -> PathBuf { let link = self.dist_root.join(link_name); symlink(&self.shared_env.jbr_root, &link).unwrap(); link } pub fn create_launcher_link(&self, link_name: &str) -> PathBuf { let link = self.project_dir.join(link_name); symlink(&self.launcher_path, &link).unwrap(); link } pub fn create_temp_dir(&self, relative_path: &str) -> PathBuf { let temp_dir = self.test_root_dir.path().join(relative_path); fs::create_dir_all(&temp_dir).unwrap_or_else(|_| panic!("Cannot create: {:?}", temp_dir)); temp_dir } pub fn create_temp_file(&self, relative_path: &str, content: &str) -> PathBuf { let temp_file = self.test_root_dir.path().join(relative_path); Self::create_file(&temp_file, content); temp_file } pub fn create_user_config_file(&mut self, name: &str, content: &str, custom_config_dir: PathBuf) -> PathBuf { self.to_delete.push(custom_config_dir.clone()); let config_file = custom_config_dir.join(name); Self::create_file(&config_file, content); config_file } pub fn create_toolbox_vm_options(&mut self, content: &str) -> PathBuf { let dist_root = if cfg!(target_os = "macos") { self.dist_root.parent().unwrap() } else { &self.dist_root }; let vm_options_name = dist_root.file_name().unwrap().to_str().unwrap().to_string() + ".vmoptions"; let vm_options_file = dist_root.parent().unwrap().join(vm_options_name); self.to_delete.push(vm_options_file.clone()); Self::create_file(&vm_options_file, content); vm_options_file } fn create_file(file: &Path, content: &str) { fs::create_dir_all(file.parent().unwrap()) .unwrap_or_else(|_| panic!("Cannot create: {:?}", file)); OpenOptions::new().write(true).create_new(true).open(file) .unwrap_or_else(|_| panic!("Cannot create {:?}", &file)) .write_all(content.as_bytes()) .unwrap_or_else(|_| panic!("Cannot write {:?}", &file)); } #[cfg(target_os = "windows")] pub fn to_unc(&self) -> Self { self.map_path(Self::convert_to_unc) } #[cfg(target_os = "windows")] pub fn to_ns_prefix(&self) -> Self { self.map_path(Self::ns_prefix) } #[cfg(target_os = "windows")] fn map_path(&self, mapping: fn(&Path) -> PathBuf) -> Self { let new_temp_dir = mapping(self.test_root_dir.path().parent().unwrap()); TestEnvironment { dist_root: mapping(&self.dist_root), project_dir: mapping(&self.project_dir), launcher_path: mapping(&self.launcher_path), shared_env: self.shared_env, test_root_dir: Builder::new().prefix("xplat_launcher_test_").tempdir_in(new_temp_dir).unwrap(), to_delete: Vec::new() } } // "C:\some\path" -> "\\127.0.0.1\\C$\some\path" #[cfg(target_os = "windows")] fn convert_to_unc(path: &Path) -> PathBuf { assert!(path.has_root(), "Invalid path: {:?}", path); let path_str = path.to_str().unwrap(); PathBuf::from(String::from("\\\\127.0.0.1\\") + &path_str[0..1] + "$" + &path_str[2..]) } // "C:\some\path" -> "\\?\C:\some\path" #[cfg(target_os = "windows")] fn ns_prefix(path: &Path) -> PathBuf { assert!(path.has_root(), "Invalid path: {:?}", path); PathBuf::from(String::from("\\\\?\\") + path.to_str().unwrap()) } } impl<'a> Drop for TestEnvironment<'a> { fn drop(&mut self) { for path in &self.to_delete { debug!("Deleting {:?}", path); if let Ok(metadata) = path.symlink_metadata() { let result = if metadata.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }; result.unwrap_or_else(|_| panic!("cannot delete: {:?}", path)) } } } } pub fn prepare_test_env<'a>(launcher_location: LauncherLocation) -> TestEnvironment<'a> { prepare_custom_test_env(launcher_location, None, true) } pub fn prepare_custom_test_env<'a>( launcher_location: LauncherLocation, dir_suffix: Option<&str>, with_jbr: bool ) -> TestEnvironment<'a> { match prepare_test_env_impl(launcher_location, dir_suffix, with_jbr) { Ok(x) => x, Err(e) => panic!("Failed to prepare test environment: {:?}", e), } } struct TestEnvironmentShared { launcher_path: PathBuf, jbr_root: PathBuf, app_jar_path: PathBuf, product_info_path: PathBuf, vm_options_path: PathBuf, #[allow(dead_code)] temp_dir: TempDir } fn prepare_test_env_impl<'a>( launcher_location: LauncherLocation, dir_suffix: Option<&str>, with_jbr: bool ) -> Result> { INIT.call_once(|| { let shared = init_test_environment_once().context("Failed to init shared test environment").unwrap(); unsafe { SHARED = Some(shared) } }); let shared_env = unsafe { SHARED.as_ref() }.expect("Shared test environment should have already been initialized"); let prefix = if let Some(s) = dir_suffix { format!("launcher_test_{s}_") } else { "launcher_test_".to_string() }; let temp_dir = Builder::new().prefix(&prefix).tempdir().context("Failed to create temp directory")?; let temp_path = temp_dir.path().canonicalize()?.strip_ns_prefix()?; let (dist_root, launcher_path) = layout_launcher(launcher_location, with_jbr, &temp_path, shared_env)?; let project_dir = temp_path.join("_project"); fs::create_dir_all(&project_dir)?; Ok(TestEnvironment { dist_root, project_dir, launcher_path, shared_env, test_root_dir: temp_dir, to_delete: Vec::new() }) } fn init_test_environment_once() -> Result { //xplat_launcher::mini_logger::init(log::LevelFilter::Debug)?; // clean environment variables env::remove_var("JDK_HOME"); env::remove_var("JAVA_HOME"); let project_root = env::current_dir().expect("Failed to get project root"); let bin_name = if cfg!(target_os = "windows") { "xplat-launcher.exe" } else { "xplat-launcher" }; let launcher_file = env::current_exe()?.parent_or_err()?.parent_or_err()?.join(bin_name); if !launcher_file.exists() { bail!("Didn't find source launcher to layout, expected path: {:?}", launcher_file); } let gradle_build_dir = Path::new("./resources/TestProject/build"); if !gradle_build_dir.is_dir() { bail!("Missing: {:?}; please run `gradlew :downloadJbr :fatJar` first", gradle_build_dir); } let gradle_build_dir = gradle_build_dir.canonicalize()?.strip_ns_prefix()?; let jbr_root = gradle_build_dir.join("jbr"); let app_jar_file = gradle_build_dir.join("libs/app.jar"); let product_info_path = project_root.join(format!("resources/product_info_{}.json", env::consts::OS)); let vm_options_path = project_root.join("resources/xplat.vmoptions"); // on build agents, a temp directory may reside in a different filesystem, so copies are necessary for later linking let temp_dir = Builder::new().prefix("xplat_launcher_shared_").tempdir().context("Failed to create temp directory")?; let launcher_path = temp_dir.path().join(launcher_file.file_name().unwrap()); fs::copy(&launcher_file, &launcher_path).with_context(|| format!("Failed to copy {launcher_file:?} to {launcher_path:?}"))?; let app_jar_path = temp_dir.path().join(app_jar_file.file_name().unwrap()); fs::copy(&app_jar_file, &app_jar_path).with_context(|| format!("Failed to copy {app_jar_file:?} to {app_jar_path:?}"))?; Ok(TestEnvironmentShared { launcher_path, jbr_root, app_jar_path, product_info_path, vm_options_path, temp_dir }) } #[cfg(target_os = "linux")] fn layout_launcher( launcher_location: LauncherLocation, include_jbr: bool, target_dir: &Path, shared_env: &TestEnvironmentShared ) -> Result<(PathBuf, PathBuf)> { // . // └── XPlatLauncher // ├── bin/ // │ └── xplat-launcher | remote-dev-server // │ └── xplat64.vmoptions // │ └── idea.properties // ├── lib/ // │ └── app.jar // │ └── boot-linux.jar // ├── jbr/ // └── product-info.json let launcher_rel_path = match launcher_location { LauncherLocation::Standard => "bin/xplat-launcher", LauncherLocation::RemoteDev => "bin/remote-dev-server" }; let dist_root = target_dir.join("XPlatLauncher"); layout_launcher_impl( &dist_root, vec![ "bin/idea.properties", "lib/boot-linux.jar" ], vec![ (&shared_env.launcher_path, launcher_rel_path), (&shared_env.app_jar_path, "lib/app.jar") ], vec![ (&shared_env.vm_options_path, "bin/xplat64.vmoptions"), (&shared_env.product_info_path, "product-info.json") ], include_jbr, &shared_env.jbr_root )?; let launcher_path = dist_root.join(launcher_rel_path); Ok((dist_root, launcher_path)) } #[cfg(target_os = "macos")] fn layout_launcher( launcher_location: LauncherLocation, include_jbr: bool, target_dir: &Path, shared_env: &TestEnvironmentShared ) -> Result<(PathBuf, PathBuf)> { // . // └── XPlatLauncher.app // └── Contents // ├── bin/ // │ └── remote-dev-server [::RemoteDev] // │ └── xplat.vmoptions // │ └── idea.properties // ├── MacOS/ // │ └── xplat-launcher [::Standard] // ├── Resources/ // │ └── product-info.json // ├── lib/ // │ └── app.jar // │ └── boot-macos.jar // ├── jbr/ // └── Info.plist let launcher_rel_path = match launcher_location { LauncherLocation::Standard => "MacOS/xplat-launcher", LauncherLocation::RemoteDev => "bin/remote-dev-server" }; let dist_root = target_dir.join("XPlatLauncher.app/Contents"); let info_plist_path = shared_env.vm_options_path.parent_or_err()?.join("Info.plist"); layout_launcher_impl( &dist_root, vec![ "bin/idea.properties", "lib/boot-macos.jar" ], vec![ (&shared_env.launcher_path, launcher_rel_path), (&shared_env.app_jar_path, "lib/app.jar") ], vec![ (&shared_env.vm_options_path, "bin/xplat.vmoptions"), (&shared_env.product_info_path, "Resources/product-info.json"), (&info_plist_path, "Info.plist") ], include_jbr, &shared_env.jbr_root )?; let launcher_path = dist_root.join(launcher_rel_path); Ok((dist_root, launcher_path)) } #[cfg(target_os = "windows")] fn layout_launcher( launcher_location: LauncherLocation, include_jbr: bool, target_dir: &Path, shared_env: &TestEnvironmentShared ) -> Result<(PathBuf, PathBuf)> { // . // └── XPlatLauncher // ├── bin/ // │ └── xplat64.exe | remote-dev-server.exe // │ └── xplat64.exe.vmoptions // │ └── idea.properties // ├── lib/ // │ └── app.jar // │ └── boot-windows.jar // ├── jbr/ // └── product-info.json let launcher_rel_path = match launcher_location { LauncherLocation::Standard => "bin\\xplat64.exe", LauncherLocation::RemoteDev => "bin\\remote-dev-server.exe" }; let dist_root = target_dir.join("XPlatLauncher"); layout_launcher_impl( &dist_root, vec![ "bin\\idea.properties", "lib\\boot-windows.jar" ], vec![ (&shared_env.launcher_path, launcher_rel_path), (&shared_env.app_jar_path, "lib\\app.jar") ], vec![ (&shared_env.vm_options_path, "bin\\xplat64.exe.vmoptions"), (&shared_env.product_info_path, "product-info.json") ], include_jbr, &shared_env.jbr_root )?; let launcher_path = dist_root.join(launcher_rel_path); Ok((dist_root, launcher_path)) } fn layout_launcher_impl( target_dir: &Path, create_files: Vec<&str>, link_files: Vec<(&Path, &str)>, copy_files: Vec<(&Path, &str)>, include_jbr: bool, jbr_path: &Path ) -> Result<()> { for target_rel_path in create_files { let target = &target_dir.join(target_rel_path); fs::create_dir_all(target.parent_or_err()?).with_context(|| format!("Failed to create dir {:?}", target.parent()))?; File::create(target).with_context(|| format!("Failed to create file {target:?}"))?; } for (source, target_rel_path) in link_files { let target = &target_dir.join(target_rel_path); fs::create_dir_all(target.parent_or_err()?)?; fs::hard_link(source, target).with_context(|| format!("Failed to create hardlink {target:?} -> {source:?}"))?; } for (source, target_rel_path) in copy_files { let target = &target_dir.join(target_rel_path); fs::create_dir_all(target.parent_or_err()?)?; fs::copy(source, target).with_context(|| format!("Failed to copy {source:?} to {target:?}"))?; } if include_jbr { symlink(jbr_path, &target_dir.join("jbr"))?; } Ok(()) } #[cfg(target_family = "unix")] fn symlink(original: &Path, link: &Path) -> Result<()> { std::os::unix::fs::symlink(original, link).with_context(|| format!("Failed to create symlink {link:?} -> {original:?}"))?; Ok(()) } #[cfg(target_os = "windows")] fn symlink(original: &Path, link: &Path) -> Result<()> { let result = match original.is_dir() { true => std::os::windows::fs::symlink_dir(original, link), false => std::os::windows::fs::symlink_file(original, link) }; let message = match &result { Ok(_) => "", Err(e) if e.raw_os_error() == Some(1314) => "Cannot use CreateSymbolicLink.\ Consider having a privilege to do that or enabling Developer Mode", Err(_) => "Failed to create a symlink for a reason unrelated to privileges", }; result.with_context(|| format!("Failed to create symlink {link:?} -> {original:?}; {message}")) } pub struct LauncherRunSpec { location: LauncherLocation, dump: bool, assert_status: bool, args: Vec, env: HashMap } impl LauncherRunSpec { pub fn standard() -> LauncherRunSpec { LauncherRunSpec { location: LauncherLocation::Standard, dump: false, assert_status: false, args: Vec::new(), env: HashMap::new() } } pub fn remote_dev() -> LauncherRunSpec { LauncherRunSpec { location: LauncherLocation::RemoteDev, dump: false, assert_status: false, args: Vec::new(), env: HashMap::new() } } pub fn with_dump(&mut self) -> &mut Self { self.dump = true; self } pub fn assert_status(&mut self) -> &mut Self { self.assert_status = true; self } pub fn with_args(&mut self, args: &[&str]) -> &mut Self { self.args.extend(args.iter().map(|s| s.to_string())); self } pub fn with_env(&mut self, env: &HashMap<&str, &str>) -> &mut Self { self.env.extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string()))); self } } pub struct LauncherRunResult { pub exit_status: ExitStatus, pub stdout: String, pub stderr: String, dump: Option>, } impl LauncherRunResult { pub fn dump(self) -> IntellijMainDumpedLaunchParameters { let err_message = format!("Dump was not collected: {:?}", self); match self.dump { Some(result) => result.expect(&err_message), None => panic!("Dump was not requested; add `.with_dump()` to the run specification") } } } impl Debug for LauncherRunResult { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "\n** exit code: {:?} ({:?})\n** stderr: <<<{}>>>\n** stdout: <<<{}>>>", self.exit_status.code(), self.exit_status, self.stderr, self.stdout)) } } #[allow(non_snake_case)] #[derive(Deserialize, Serialize, Clone, Debug)] pub struct IntellijMainDumpedLaunchParameters { pub cmdArguments: Vec, pub vmOptions: Vec, pub environmentVariables: HashMap, pub systemProperties: HashMap } pub fn run_launcher(run_spec: &LauncherRunSpec) -> LauncherRunResult { let test_env = prepare_test_env(run_spec.location.clone()); run_launcher_ext(&test_env, run_spec) } pub fn run_launcher_ext(test_env: &TestEnvironment, run_spec: &LauncherRunSpec) -> LauncherRunResult { match run_launcher_impl(test_env, run_spec) { Ok(result) => { if run_spec.assert_status { assert!(result.exit_status.success(), "The exit status of the launcher is not successful: {:?}", result); } result } Err(e) => { panic!("Failed to get launcher run result: {:?}", e) } } } fn run_launcher_impl(test_env: &TestEnvironment, run_spec: &LauncherRunSpec) -> Result { debug!("Starting '{}'\n with args {:?}\n in '{}'", test_env.launcher_path.display(), run_spec.args, test_env.test_root_dir.path().display()); let stdout_file_path = &test_env.test_root_dir.path().join("out.txt"); let stderr_file_path = &test_env.test_root_dir.path().join("err.txt"); let project_dir = test_env.project_dir.to_str().unwrap(); let dump_file_path = test_env.test_root_dir.path().join("output.json"); let dump_file_path_str = dump_file_path.strip_ns_prefix()?.to_string_checked()?; let mut full_args = Vec::<&str>::new(); if run_spec.dump { let dump_args = match run_spec.location { LauncherLocation::Standard => vec!["dump-launch-parameters", "--output", &dump_file_path_str], LauncherLocation::RemoteDev => vec!["dumpLaunchParameters", project_dir, "--output", &dump_file_path_str] }; for arg in dump_args { full_args.push(arg); } } for arg in run_spec.args.iter() { full_args.push(arg); } let mut full_env = match run_spec.location { LauncherLocation::Standard => HashMap::from([ (DEBUG_MODE_ENV_VAR, "1") ]), LauncherLocation::RemoteDev => HashMap::from([ ("CWM_NO_PASSWORD", "1"), ("CWM_HOST_PASSWORD", "1"), ("REMOTE_DEV_NON_INTERACTIVE", "1"), ("IJ_HOST_CONFIG_DIR", project_dir), ("IJ_HOST_SYSTEM_DIR", project_dir), ("IJ_HOST_LOGS_DIR", project_dir) ]), }; for (k, v) in run_spec.env.iter() { full_env.insert(k, v); } let stdout_file = File::create(stdout_file_path) .context(format!("Failed to create stdout file at {stdout_file_path:?}"))?; let stderr_file = File::create(stderr_file_path) .context(format!("Failed to create stderr file at {stderr_file_path:?}"))?; let mut launcher_process = Command::new(&test_env.launcher_path) .current_dir(test_env.test_root_dir.path()) .args(full_args) .stdout(Stdio::from(stdout_file)) .stderr(Stdio::from(stderr_file)) .envs(full_env) .spawn() .context("Failed to spawn launcher process")?; let started = time::Instant::now(); loop { let elapsed = time::Instant::now() - started; if elapsed > time::Duration::from_secs(60) { panic!("Launcher has been running for more than 60 seconds, terminating") } if let Some(es) = launcher_process.try_wait()? { return Ok(LauncherRunResult { exit_status: es, stdout: read_output_file(stdout_file_path).context("Cannot read stdout file")?, stderr: read_output_file(stderr_file_path).context("Cannot read stderr file")?, dump: if run_spec.dump { Some(read_launcher_run_result(&dump_file_path)) } else { None } }); } thread::sleep(time::Duration::from_secs(1)) } } fn read_output_file(path: &Path) -> Result { let bytes = fs::read(path).with_context(|| format!("Cannot open {:?}", path))?; if let Ok(string) = String::from_utf8(bytes.to_owned()) { Ok(string) } else { for line in bytes.split(|b| *b == b'\n') { if let Err(e) = String::from_utf8(line.to_owned()) { bail!("{}: {:?} {:?}", e, line, String::from_utf8_lossy(line)) } } panic!("Should not reach here"); } } fn read_launcher_run_result(path: &Path) -> Result { let file = File::open(path)?; let mut reader = BufReader::new(file); let mut text = String::new(); reader.read_to_string(&mut text)?; let dump: IntellijMainDumpedLaunchParameters = serde_json::from_str(&text)?; Ok(dump) } pub fn test_runtime_selection(result: LauncherRunResult, expected_rt: PathBuf) { let rt_line = result.stdout.lines() .find(|line| line.contains("Resolved runtime: ")) .unwrap_or_else(|| panic!("The 'Resolved runtime:' line is not in the output: {}", result.stdout)); let resolved_rt = rt_line.split_once("Resolved runtime: ").unwrap().1; let actual_rt = &resolved_rt[1..resolved_rt.len() - 1].replace("\\\\", "\\"); let adjusted_rt = if cfg!(target_os = "macos") { expected_rt.join("Contents/Home") } else { expected_rt }; assert_eq!(adjusted_rt.to_str().unwrap(), actual_rt, "Wrong runtime; run result: {:?}", result); } pub fn assert_vm_option_presence(dump: &IntellijMainDumpedLaunchParameters, vm_option: &str) { assert!(dump.vmOptions.contains(&vm_option.to_string()), "{:?} is not in {:?}", vm_option, dump.vmOptions); } pub fn assert_vm_option_absence(dump: &IntellijMainDumpedLaunchParameters, vm_option: &str) { assert!(!dump.vmOptions.contains(&vm_option.to_string()), "{:?} should not be in {:?}", vm_option, dump.vmOptions); }