// Copyright 2020 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::cell::RefCell; use std::collections::HashMap; use std::path::{Path, PathBuf}; use regex::{Captures, Regex}; use tempfile::TempDir; use testutils; pub struct TestEnvironment { _temp_dir: TempDir, env_root: PathBuf, home_dir: PathBuf, config_path: PathBuf, env_vars: HashMap, config_file_number: RefCell, command_number: RefCell, /// If true, `jj_cmd_success` does not abort when `jj` exits with nonempty /// stderr and outputs it instead. This is meant only for debugging. /// /// This allows debugging the execution of an integration test by inserting /// `eprintln!` or `dgb!` statements in relevant parts of jj source code. /// /// You can change the value of this parameter directly, or you can set the /// `JJ_DEBUG_ALLOW_STDERR` environment variable. /// /// To see the output, you can run `cargo test` with the --show-output /// argument, like so: /// /// RUST_BACKTRACE=1 JJ_DEBUG_ALLOW_STDERR=1 cargo test \ /// --test test_git_colocated -- --show-output fetch_del /// /// This would run all the tests that contain `fetch_del` in their name in a /// file that contains `test_git_colocated` and show the output. pub debug_allow_stderr: bool, } impl Default for TestEnvironment { fn default() -> Self { testutils::hermetic_libgit2(); let tmp_dir = testutils::new_temp_dir(); let env_root = tmp_dir.path().canonicalize().unwrap(); let home_dir = env_root.join("home"); std::fs::create_dir(&home_dir).unwrap(); let config_dir = env_root.join("config"); std::fs::create_dir(&config_dir).unwrap(); let env_vars = HashMap::new(); let env = Self { _temp_dir: tmp_dir, env_root, home_dir, config_path: config_dir, env_vars, config_file_number: RefCell::new(0), command_number: RefCell::new(0), debug_allow_stderr: std::env::var("JJ_DEBUG_ALLOW_STDERR").is_ok(), }; // Use absolute timestamps in the operation log to make tests independent of the // current time. env.add_config( r#" [template-aliases] 'format_time_range(time_range)' = 'time_range.start() ++ " - " ++ time_range.end()' "#, ); env } } impl TestEnvironment { pub fn jj_cmd(&self, current_dir: &Path, args: &[&str]) -> assert_cmd::Command { let mut cmd = assert_cmd::Command::cargo_bin("jj").unwrap(); cmd.current_dir(current_dir); cmd.args(args); cmd.env_clear(); for (key, value) in &self.env_vars { cmd.env(key, value); } cmd.env("RUST_BACKTRACE", "1"); cmd.env("HOME", self.home_dir.to_str().unwrap()); cmd.env("JJ_CONFIG", self.config_path.to_str().unwrap()); cmd.env("JJ_USER", "Test User"); cmd.env("JJ_EMAIL", "test.user@example.com"); cmd.env("JJ_OP_HOSTNAME", "host.example.com"); cmd.env("JJ_OP_USERNAME", "test-username"); let mut command_number = self.command_number.borrow_mut(); *command_number += 1; cmd.env("JJ_RANDOMNESS_SEED", command_number.to_string()); let timestamp = chrono::DateTime::parse_from_rfc3339("2001-02-03T04:05:06+07:00").unwrap(); let timestamp = timestamp + chrono::Duration::seconds(*command_number); cmd.env("JJ_TIMESTAMP", timestamp.to_rfc3339()); cmd.env("JJ_OP_TIMESTAMP", timestamp.to_rfc3339()); #[cfg(all(windows, target_env = "gnu"))] { use itertools::Itertools as _; // MinGW executables cannot run without `mingw\bin` in the PATH (which we're // clearing above), so we add it again here. if let Ok(path_var) = std::env::var("PATH").or_else(|_| std::env::var("Path")) { // There can be slight variations of this path (e.g. `mingw64\bin`) so we're // intentionally being lenient here. let mingw_directories = path_var .split(';') .filter(|dir| dir.contains("mingw")) .join(";"); if !mingw_directories.is_empty() { cmd.env("PATH", mingw_directories); } } // MinGW uses `TEMP` to create temporary directories, which we need for some // tests. if let Ok(tmp_var) = std::env::var("TEMP") { cmd.env("TEMP", tmp_var); } } cmd } /// Run a `jj` command, check that it was successful, and return its /// `(stdout, stderr)`. pub fn jj_cmd_ok(&self, current_dir: &Path, args: &[&str]) -> (String, String) { let assert = self.jj_cmd(current_dir, args).assert().success(); let stdout = self.normalize_output(&get_stdout_string(&assert)); let stderr = self.normalize_output(&get_stderr_string(&assert)); (stdout, stderr) } /// Run a `jj` command, check that it was successful, and return its stdout pub fn jj_cmd_success(&self, current_dir: &Path, args: &[&str]) -> String { if self.debug_allow_stderr { let (stdout, stderr) = self.jj_cmd_ok(current_dir, args); if !stderr.is_empty() { eprintln!( "==== STDERR from running jj with {args:?} args in {current_dir:?} \ ====\n{stderr}==== END STDERR ====" ); } stdout } else { let assert = self.jj_cmd(current_dir, args).assert().success().stderr(""); self.normalize_output(&get_stdout_string(&assert)) } } /// Run a `jj` command, check that it failed with code 1, and return its /// stderr #[must_use] pub fn jj_cmd_failure(&self, current_dir: &Path, args: &[&str]) -> String { let assert = self.jj_cmd(current_dir, args).assert().code(1).stdout(""); self.normalize_output(&get_stderr_string(&assert)) } /// Run a `jj` command and check that it failed with code 2 (for invalid /// usage) #[must_use] pub fn jj_cmd_cli_error(&self, current_dir: &Path, args: &[&str]) -> String { let assert = self.jj_cmd(current_dir, args).assert().code(2).stdout(""); self.normalize_output(&get_stderr_string(&assert)) } /// Run a `jj` command, check that it failed with code 255, and return its /// stderr #[must_use] pub fn jj_cmd_internal_error(&self, current_dir: &Path, args: &[&str]) -> String { let assert = self.jj_cmd(current_dir, args).assert().code(255).stdout(""); self.normalize_output(&get_stderr_string(&assert)) } pub fn env_root(&self) -> &Path { &self.env_root } pub fn home_dir(&self) -> &Path { &self.home_dir } pub fn config_path(&self) -> &PathBuf { &self.config_path } pub fn set_config_path(&mut self, config_path: PathBuf) { self.config_path = config_path; } pub fn add_config(&self, content: &str) { if self.config_path.is_file() { panic!("add_config not supported when config_path is a file"); } // Concatenating two valid TOML files does not (generally) result in a valid // TOML file, so we create a new file every time instead. let mut config_file_number = self.config_file_number.borrow_mut(); *config_file_number += 1; let config_file_number = *config_file_number; std::fs::write( self.config_path .join(format!("config{config_file_number:04}.toml")), content, ) .unwrap(); } pub fn add_env_var(&mut self, key: &str, val: &str) { self.env_vars.insert(key.to_string(), val.to_string()); } /// Sets up the fake editor to read an edit script from the returned path /// Also sets up the fake editor as a merge tool named "fake-editor" pub fn set_up_fake_editor(&mut self) -> PathBuf { let editor_path = assert_cmd::cargo::cargo_bin("fake-editor"); assert!(editor_path.is_file()); // Simplified TOML escaping, hoping that there are no '"' or control characters // in it let escaped_editor_path = editor_path.to_str().unwrap().replace('\\', r"\\"); self.add_env_var("EDITOR", &escaped_editor_path); self.add_config(&format!( r###" [ui] merge-editor = "fake-editor" [merge-tools] fake-editor.program="{escaped_editor_path}" fake-editor.merge-args = ["$output"] "### )); let edit_script = self.env_root().join("edit_script"); std::fs::write(&edit_script, "").unwrap(); self.add_env_var("EDIT_SCRIPT", edit_script.to_str().unwrap()); edit_script } /// Sets up the fake diff-editor to read an edit script from the returned /// path pub fn set_up_fake_diff_editor(&mut self) -> PathBuf { let diff_editor_path = assert_cmd::cargo::cargo_bin("fake-diff-editor"); assert!(diff_editor_path.is_file()); // Simplified TOML escaping, hoping that there are no '"' or control characters // in it let escaped_diff_editor_path = diff_editor_path.to_str().unwrap().replace('\\', r"\\"); self.add_config(&format!(r#"ui.diff-editor = "{escaped_diff_editor_path}""#)); let edit_script = self.env_root().join("diff_edit_script"); std::fs::write(&edit_script, "").unwrap(); self.add_env_var("DIFF_EDIT_SCRIPT", edit_script.to_str().unwrap()); edit_script } pub fn normalize_output(&self, text: &str) -> String { let text = text.replace("jj.exe", "jj"); let regex = Regex::new(&format!( r"{}(\S+)", regex::escape(&self.env_root.display().to_string()) )) .unwrap(); regex .replace_all(&text, |caps: &Captures| { format!("$TEST_ENV{}", caps[1].replace('\\', "/")) }) .to_string() } /// Used before mutating operations to create more predictable commit ids /// and change ids in tests /// /// `test_env.advance_test_rng_seed_to_multiple_of(200_000)` can be inserted /// wherever convenient throughout your test. If desired, you can have /// "subheadings" with steps of (e.g.) 10_000, 500, 25. pub fn advance_test_rng_seed_to_multiple_of(&self, step: i64) { assert!(step > 0, "step must be >0, got {step}"); let mut command_number = self.command_number.borrow_mut(); *command_number = step * (*command_number / step) + step; } } pub fn get_stdout_string(assert: &assert_cmd::assert::Assert) -> String { String::from_utf8(assert.get_output().stdout.clone()).unwrap() } pub fn get_stderr_string(assert: &assert_cmd::assert::Assert) -> String { String::from_utf8(assert.get_output().stderr.clone()).unwrap() }