jj/cli/tests/common/mod.rs
jyn d66fcf2ca0 compile integration tests as a single binary
this greatly speeds up the time to run all tests, at the cost of slightly larger recompile times for individual tests.

this unfortunately adds the requirement that all tests are listed in `runner.rs` for the crate.
to avoid forgetting, i've added a new test that ensures the directory is in sync with the file.

 ## benchmarks

before this change, recompiling all tests took 32-50 seconds and running a single test took 3.5 seconds:

```
; hyperfine 'touch lib/src/lib.rs && cargo t --test test_working_copy'
  Time (mean ± σ):      3.543 s ±  0.168 s    [User: 2.597 s, System: 1.262 s]
  Range (min … max):    3.400 s …  3.847 s    10 runs
```

after this change, recompiling all tests take 4 seconds:
```
;  hyperfine 'touch lib/src/lib.rs ; cargo t --test runner --no-run'
  Time (mean ± σ):      4.055 s ±  0.123 s    [User: 3.591 s, System: 1.593 s]
  Range (min … max):    3.804 s …  4.159 s    10 runs
```
and running a single test takes about the same:
```
; hyperfine 'touch lib/src/lib.rs && cargo t --test runner -- test_working_copy'
  Time (mean ± σ):      4.129 s ±  0.120 s    [User: 3.636 s, System: 1.593 s]
  Range (min … max):    3.933 s …  4.346 s    10 runs
```

about 1.4 seconds of that is the time for the runner, of which .4 is the time for the linker. so
there may be room for further improving the times.
2024-02-06 18:19:41 -08:00

360 lines
13 KiB
Rust

// 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 itertools::Itertools as _;
use regex::{Captures, Regex};
use tempfile::TempDir;
pub struct TestEnvironment {
_temp_dir: TempDir,
env_root: PathBuf,
home_dir: PathBuf,
config_path: PathBuf,
env_vars: HashMap<String, String>,
config_file_number: RefCell<i64>,
command_number: RefCell<i64>,
/// 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());
// libgit2 always initializes OpenSSL, and it takes a few tens of milliseconds
// to load the system CA certificates in X509_load_cert_crl_file_ex(). As we
// don't use HTTPS in our tests, we can disable the cert loading to speed up the
// CLI tests. If we migrated to gitoxide, maybe we can remove this hack.
if cfg!(unix) {
cmd.env("SSL_CERT_FILE", "/dev/null");
}
if cfg!(all(windows, target_env = "gnu")) {
// 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
}
pub fn write_stdin(&self, cmd: &mut assert_cmd::Command, stdin: &str) {
cmd.env("JJ_INTERACTIVE", "1");
cmd.write_stdin(stdin);
}
pub fn jj_cmd_stdin(
&self,
current_dir: &Path,
args: &[&str],
stdin: &str,
) -> assert_cmd::Command {
let mut cmd = self.jj_cmd(current_dir, args);
self.write_stdin(&mut cmd, stdin);
cmd
}
fn get_ok(&self, mut cmd: assert_cmd::Command) -> (String, String) {
let assert = cmd.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, stderr)`.
pub fn jj_cmd_ok(&self, current_dir: &Path, args: &[&str]) -> (String, String) {
self.get_ok(self.jj_cmd(current_dir, args))
}
pub fn jj_cmd_stdin_ok(
&self,
current_dir: &Path,
args: &[&str],
stdin: &str,
) -> (String, String) {
self.get_ok(self.jj_cmd_stdin(current_dir, args, stdin))
}
/// Run a `jj` command, check that it was successful, and return its stdout
#[track_caller]
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))
}
/// Run a `jj` command, check that it failed with code 101, and return its
/// stderr
#[must_use]
pub fn jj_cmd_panic(&self, current_dir: &Path, args: &[&str]) -> String {
let assert = self.jj_cmd(current_dir, args).assert().code(101).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());
}
pub fn current_operation_id(&self, repo_path: &Path) -> String {
let id_and_newline =
self.jj_cmd_success(repo_path, &["debug", "operation", "--display=id"]);
id_and_newline.trim_end().to_owned()
}
/// 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 escaped_diff_editor_path = escaped_fake_diff_editor_path();
self.add_config(&format!(
r###"
ui.diff-editor = "fake-diff-editor"
merge-tools.fake-diff-editor.program = "{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;
}
}
#[track_caller]
pub fn get_stdout_string(assert: &assert_cmd::assert::Assert) -> String {
String::from_utf8(assert.get_output().stdout.clone()).unwrap()
}
#[track_caller]
pub fn get_stderr_string(assert: &assert_cmd::assert::Assert) -> String {
String::from_utf8(assert.get_output().stderr.clone()).unwrap()
}
pub fn escaped_fake_diff_editor_path() -> String {
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
diff_editor_path.to_str().unwrap().replace('\\', r"\\")
}