Files
openide/native/XPlatLauncher/tests/utils/mod.rs
Roman Shevchenko 08244e6c15 [tests] improving remote dev launcher test diagnostics
GitOrigin-RevId: 437c45b2711c683578dbab03171760f271b9f3f2
2024-09-11 09:25:26 +00:00

641 lines
23 KiB
Rust

// 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<TestEnvironmentShared> = 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<PathBuf>
}
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<TestEnvironment<'a>> {
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<TestEnvironmentShared> {
//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<String>,
env: HashMap<String, String>
}
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<Result<IntellijMainDumpedLaunchParameters>>,
}
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 status: {} (code: {:?})\n** stderr: <<<{}>>>\n** stdout: <<<{}>>>",
self.exit_status, self.exit_status.code(), self.stderr, self.stdout))
}
}
#[allow(non_snake_case)]
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct IntellijMainDumpedLaunchParameters {
pub cmdArguments: Vec<String>,
pub vmOptions: Vec<String>,
pub environmentVariables: HashMap<String, String>,
pub systemProperties: HashMap<String, String>
}
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<LauncherRunResult> {
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<String> {
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<IntellijMainDumpedLaunchParameters> {
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);
}