Files
helix/helix-term/tests/test/helpers.rs
2d903f82ba workspace trust: v2 (#15857)
* workspace trust: v2

Simplifies the previous implementation: user is prompted to either trust
or deny, esc skips the prompt for now. Less choice fatigue and matches
other implementations.

Unlike other editors, we follow direnv implementation: .helix/ contents
are hashed and a change to the config requires the user to re-approve.
This avoids attack vectors where the user trusts the workspace but pulls
in an untrusted PR branch. To make the hashing safe I had to pull in
sha2, we could in practice use sha1 that's already in the dependency
tree via gix, but that seems like a bad cryptographic choice in 2026.

The on-disk file layout changed to use a directory with a file per
filepath, this avoids concurrent conflicts if you have multiple different
editor instances writing to the trust config.

If the workspace is running in untrusted mode and trusting would make a
meaningful change (local config exists, or in level=none a language
server would start), then we show an indicator on the bottom corner of
the editor.

There's still a problem with git config: smudge filters still get
executed and disabling that in untrusted mode would break autocrlf diffs.
Since we only leverage gix for diffing, I'm hoping upstream could
consider a patch for this.

* Address lints

* Address clippy lint

* Break doc links to private items

Should get the lints CI green

* fix: gix regression

* Rename all to insecure, reintroduce globs

* fix lint

* Remove unused function

---------

Co-authored-by: Michael Davis <mcarsondavis@gmail.com>
2026-06-20 19:41:46 +09:00

469 lines
14 KiB
Rust

use std::{
io::{Read, Write},
mem::replace,
path::PathBuf,
time::Duration,
};
use anyhow::bail;
use helix_core::{diagnostic::Severity, test, Selection, Transaction};
use helix_loader::workspace_trust::WorkspaceTrust;
use helix_term::{application::Application, args::Args, config::Config, keymap::merge_keys};
use helix_view::{
current_ref, doc,
editor::{ImplicitTrustLevelConfig, LspConfig, WordCompletion, WorkspaceTrustConfig},
input::parse_macro,
Editor,
};
use tempfile::NamedTempFile;
use tokio_stream::wrappers::UnboundedReceiverStream;
#[cfg(windows)]
use crossterm::event::{Event, KeyEvent};
#[cfg(not(windows))]
use termina::event::{Event, KeyEvent};
/// Specify how to set up the input text with line feeds
#[derive(Clone, Debug)]
pub enum LineFeedHandling {
/// Replaces all LF chars with the system's appropriate line feed character,
/// and if one doesn't exist already, appends the system's appropriate line
/// ending to the end of a string.
Native,
/// Do not modify the input text in any way. What you give is what you test.
AsIs,
}
impl LineFeedHandling {
/// Apply the line feed handling to the input string, yielding a set of
/// resulting texts with the appropriate line feed substitutions.
pub fn apply(&self, text: &str) -> String {
let line_end = match self {
LineFeedHandling::Native => helix_core::NATIVE_LINE_ENDING,
LineFeedHandling::AsIs => return text.into(),
}
.as_str();
// we can assume that the source files in this code base will always
// be LF, so indoc strings will always insert LF
let mut output = text.replace('\n', line_end);
if !output.ends_with(line_end) {
output.push_str(line_end);
}
output
}
}
impl Default for LineFeedHandling {
fn default() -> Self {
Self::Native
}
}
#[derive(Clone, Debug)]
pub struct TestCase {
pub in_text: String,
pub in_selection: Selection,
pub in_keys: String,
pub out_text: String,
pub out_selection: Selection,
pub line_feed_handling: LineFeedHandling,
}
impl<S, R, V> From<(S, R, V)> for TestCase
where
S: Into<String>,
R: Into<String>,
V: Into<String>,
{
fn from((input, keys, output): (S, R, V)) -> Self {
TestCase::from((input, keys, output, LineFeedHandling::default()))
}
}
impl<S, R, V> From<(S, R, V, LineFeedHandling)> for TestCase
where
S: Into<String>,
R: Into<String>,
V: Into<String>,
{
fn from((input, keys, output, line_feed_handling): (S, R, V, LineFeedHandling)) -> Self {
let (in_text, in_selection) = test::print(&line_feed_handling.apply(&input.into()));
let (out_text, out_selection) = test::print(&line_feed_handling.apply(&output.into()));
TestCase {
in_text,
in_selection,
in_keys: keys.into(),
out_text,
out_selection,
line_feed_handling,
}
}
}
#[inline]
pub async fn test_key_sequence(
app: &mut Application,
in_keys: Option<&str>,
test_fn: Option<&dyn Fn(&Application)>,
should_exit: bool,
) -> anyhow::Result<()> {
test_key_sequences(app, vec![(in_keys, test_fn)], should_exit).await
}
#[allow(clippy::type_complexity)]
pub async fn test_key_sequences(
app: &mut Application,
inputs: Vec<(Option<&str>, Option<&dyn Fn(&Application)>)>,
should_exit: bool,
) -> anyhow::Result<()> {
const TIMEOUT: Duration = Duration::from_millis(500);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut rx_stream = UnboundedReceiverStream::new(rx);
let num_inputs = inputs.len();
for (i, (in_keys, test_fn)) in inputs.into_iter().enumerate() {
let (view, doc) = current_ref!(app.editor);
let state = test::plain(doc.text().slice(..), doc.selection(view.id));
log::debug!("executing test with document state:\n\n-----\n\n{}", state);
if let Some(in_keys) = in_keys {
for key_event in parse_macro(in_keys)?.into_iter() {
let key = Event::Key(KeyEvent::from(key_event));
log::trace!("sending key: {:?}", key);
tx.send(Ok(key))?;
}
}
let app_exited = !app.event_loop_until_idle(&mut rx_stream).await;
if !app_exited {
let (view, doc) = current_ref!(app.editor);
let state = test::plain(doc.text().slice(..), doc.selection(view.id));
log::debug!(
"finished running test with document state:\n\n-----\n\n{}",
state
);
}
// the app should not exit from any test until the last one
if i < num_inputs - 1 && app_exited {
bail!("application exited before test function could run");
}
// verify if it exited on the last iteration if it should have and
// the inverse
if i == num_inputs - 1 && app_exited != should_exit {
bail!("expected app to exit: {} != {}", should_exit, app_exited);
}
if let Some(test) = test_fn {
test(app);
};
}
if !should_exit {
for key_event in parse_macro("<esc>:q!<ret>")?.into_iter() {
tx.send(Ok(Event::Key(KeyEvent::from(key_event))))?;
}
let event_loop = app.event_loop(&mut rx_stream);
tokio::time::timeout(TIMEOUT, event_loop).await?;
}
let errs = app.close().await;
if !errs.is_empty() {
log::error!("Errors closing app");
for err in errs {
log::error!("{}", err);
}
bail!("Error closing app");
}
Ok(())
}
pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>(
app: Option<Application>,
test_case: T,
test_fn: &dyn Fn(&Application),
should_exit: bool,
) -> anyhow::Result<()> {
let test_case = test_case.into();
let mut app = match app {
Some(app) => app,
None => Application::new(
Args::default(),
test_config(),
test_syntax_loader(None),
WorkspaceTrust::fully_trusted(),
)?,
};
let (view, doc) = helix_view::current!(app.editor);
let sel = doc.selection(view.id).clone();
// replace the initial text with the input text
let transaction = Transaction::change_by_selection(doc.text(), &sel, |_| {
(0, doc.text().len_chars(), Some((&test_case.in_text).into()))
})
.with_selection(test_case.in_selection.clone());
doc.apply(&transaction, view.id);
test_key_sequence(
&mut app,
Some(&test_case.in_keys),
Some(test_fn),
should_exit,
)
.await
}
/// Generates language config loader that merge in overrides, like a user language
/// config. The argument string must be a raw TOML document.
pub fn test_syntax_loader(overrides: Option<String>) -> helix_core::syntax::Loader {
let mut lang = helix_loader::config::default_lang_config();
if let Some(overrides) = overrides {
let override_toml = toml::from_str(&overrides).unwrap();
lang = helix_loader::merge_toml_values(lang, override_toml, 3);
}
helix_core::syntax::Loader::new(lang.try_into().unwrap()).unwrap()
}
/// Use this for very simple test cases where there is one input
/// document, selection, and sequence of key presses, and you just
/// want to verify the resulting document and selection.
pub async fn test_with_config<T: Into<TestCase>>(
app_builder: AppBuilder,
test_case: T,
) -> anyhow::Result<()> {
let test_case = test_case.into();
let app = app_builder.build()?;
test_key_sequence_with_input_text(
Some(app),
test_case.clone(),
&|app| {
let doc = doc!(app.editor);
assert_eq!(&test_case.out_text, doc.text());
let mut selections: Vec<_> = doc.selections().values().cloned().collect();
assert_eq!(1, selections.len());
let sel = selections.pop().unwrap();
assert_eq!(test_case.out_selection, sel);
},
false,
)
.await
}
pub async fn test<T: Into<TestCase>>(test_case: T) -> anyhow::Result<()> {
test_with_config(AppBuilder::default(), test_case).await
}
pub fn temp_file_with_contents<S: AsRef<str>>(
content: S,
) -> anyhow::Result<tempfile::NamedTempFile> {
let mut temp_file = tempfile::NamedTempFile::new()?;
temp_file
.as_file_mut()
.write_all(content.as_ref().as_bytes())?;
temp_file.flush()?;
temp_file.as_file_mut().sync_all()?;
Ok(temp_file)
}
/// Generates a config with defaults more suitable for integration tests
pub fn test_config() -> Config {
Config {
editor: test_editor_config(),
keys: helix_term::keymap::default(),
..Default::default()
}
}
pub fn test_editor_config() -> helix_view::editor::Config {
helix_view::editor::Config {
lsp: LspConfig {
enable: false,
..Default::default()
},
// The word-index hook accumulates per-document pending changes across
// every `DocumentDidChange` and composes them on the next event for the
// same doc id. Each test builds a fresh `Application` that reuses
// `DocumentId(1)`, so the previous test's pending change tries to
// compose with the next test's `set_input` change (different lengths,
// hits the `len_after == len` assertion).
// Until hooks can be unregistered, keep the hook quiet by turning the
// feature off here.
word_completion: WordCompletion {
enable: false,
..Default::default()
},
// Trust everything implicitly so tests don't hit popups.
workspace_trust: WorkspaceTrustConfig {
level: ImplicitTrustLevelConfig::Insecure,
..Default::default()
},
..Default::default()
}
}
/// Creates a new temporary file that is set to read only. Useful for
/// testing write failures.
pub fn new_readonly_tempfile() -> anyhow::Result<NamedTempFile> {
let mut file = tempfile::NamedTempFile::new()?;
let metadata = file.as_file().metadata()?;
let mut perms = metadata.permissions();
perms.set_readonly(true);
file.as_file_mut().set_permissions(perms)?;
Ok(file)
}
/// Creates a new temporary file in the directory that is set to read only. Useful for
/// testing write failures.
pub fn new_readonly_tempfile_in_dir(
dir: impl AsRef<std::path::Path>,
) -> anyhow::Result<NamedTempFile> {
let mut file = tempfile::NamedTempFile::new_in(dir)?;
let metadata = file.as_file().metadata()?;
let mut perms = metadata.permissions();
perms.set_readonly(true);
file.as_file_mut().set_permissions(perms)?;
Ok(file)
}
pub struct AppBuilder {
args: Args,
config: Config,
syn_loader: helix_core::syntax::Loader,
input: Option<(String, Selection)>,
}
impl Default for AppBuilder {
fn default() -> Self {
Self {
args: Args::default(),
config: test_config(),
syn_loader: test_syntax_loader(None),
input: None,
}
}
}
impl AppBuilder {
pub fn new() -> Self {
AppBuilder::default()
}
pub fn with_file<P: Into<PathBuf>>(
mut self,
path: P,
pos: Option<helix_core::Position>,
) -> Self {
self.args
.files
.insert(path.into(), vec![pos.unwrap_or_default()]);
self
}
// Remove this attribute once `with_config` is used in a test:
#[allow(dead_code)]
pub fn with_config(mut self, mut config: Config) -> Self {
let keys = replace(&mut config.keys, helix_term::keymap::default());
merge_keys(&mut config.keys, keys);
self.config = config;
self
}
pub fn with_input_text<S: Into<String>>(mut self, input_text: S) -> Self {
self.input = Some(test::print(&input_text.into()));
self
}
pub fn with_lang_loader(mut self, syn_loader: helix_core::syntax::Loader) -> Self {
self.syn_loader = syn_loader;
self
}
pub fn build(self) -> anyhow::Result<Application> {
if let Some(path) = &self.args.working_directory {
bail!("Changing the working directory to {path:?} is not yet supported for integration tests");
}
if let Some((path, _)) = self.args.files.first().filter(|p| p.0.is_dir()) {
bail!("Having the directory {path:?} in args.files[0] is not yet supported for integration tests");
}
let mut app = Application::new(
self.args,
self.config,
self.syn_loader,
WorkspaceTrust::fully_trusted(),
)?;
if let Some((text, selection)) = self.input {
let (view, doc) = helix_view::current!(app.editor);
let sel = doc.selection(view.id).clone();
let trans = Transaction::change_by_selection(doc.text(), &sel, |_| {
(0, doc.text().len_chars(), Some((text.clone()).into()))
})
.with_selection(selection);
// replace the initial text with the input text
doc.apply(&trans, view.id);
}
Ok(app)
}
}
pub async fn run_event_loop_until_idle(app: &mut Application) {
let (_, rx) = tokio::sync::mpsc::unbounded_channel();
let mut rx_stream = UnboundedReceiverStream::new(rx);
app.event_loop_until_idle(&mut rx_stream).await;
}
pub fn assert_file_has_content(file: &mut NamedTempFile, content: &str) -> anyhow::Result<()> {
reload_file(file)?;
let mut file_content = String::new();
file.read_to_string(&mut file_content)?;
assert_eq!(file_content, content);
Ok(())
}
pub fn assert_status_not_error(editor: &Editor) {
if let Some((_, sev)) = editor.get_status() {
assert_ne!(&Severity::Error, sev);
}
}
pub fn reload_file(file: &mut NamedTempFile) -> anyhow::Result<()> {
let path = file.path();
let f = std::fs::OpenOptions::new()
.write(true)
.read(true)
.open(&path)?;
*file.as_file_mut() = f;
Ok(())
}