Add test support crate for plugin developers (#12259)

# Description

Adds a `nu-plugin-test-support` crate with an interface that supports
testing plugins.

Unlike in reality, these plugins run in the same process on separate
threads. This will allow
testing aspects of the plugin internal state and handling serialized
plugin custom values easily.
We still serialize their custom values and all of the engine to plugin
logic is still in play, so
from a logical perspective this should still expose any bugs that would
have been caused by that.
The only difference is that it doesn't run in a different process, and
doesn't try to serialize
everything to the final wire format for stdin/stdout.

TODO still:

- [x] Clean up warnings about private types exposed in trait definition
- [x] Automatically deserialize plugin custom values in the result so
they can be inspected
- [x] Automatic plugin examples test function
- [x] Write a bit more documentation
- [x] More tests
- [x] Add MIT License file to new crate

# User-Facing Changes

Plugin developers get a nice way to test their plugins.

# Tests + Formatting
Run the tests with `cargo test -p nu-plugin-test-support --
--show-output` to see some examples of what the failing test output for
examples can look like. I used the `difference` crate (MIT licensed) to
make it look nice.

- 🟢 `toolkit fmt`
- 🟢 `toolkit clippy`
- 🟢 `toolkit test`
- 🟢 `toolkit test stdlib`

# After Submitting

- [ ] Add a section to the book about testing
- [ ] Test some of the example plugins this way
- [ ] Add example tests to nu_plugin_template so plugin developers have
something to start with
This commit is contained in:
Devyn Cairns
2024-03-23 11:29:54 -07:00
committed by GitHub
parent ff41cf91ef
commit c79c43d2f8
31 changed files with 1171 additions and 163 deletions

View File

@ -0,0 +1,71 @@
use std::{
any::Any,
sync::{Arc, OnceLock},
};
use nu_plugin::{GetPlugin, PluginInterface};
use nu_protocol::{
engine::{EngineState, Stack},
PluginGcConfig, PluginIdentity, RegisteredPlugin, ShellError,
};
pub struct FakePersistentPlugin {
identity: PluginIdentity,
plugin: OnceLock<PluginInterface>,
}
impl FakePersistentPlugin {
pub fn new(identity: PluginIdentity) -> FakePersistentPlugin {
FakePersistentPlugin {
identity,
plugin: OnceLock::new(),
}
}
pub fn initialize(&self, interface: PluginInterface) {
self.plugin.set(interface).unwrap_or_else(|_| {
panic!("Tried to initialize an already initialized FakePersistentPlugin");
})
}
}
impl RegisteredPlugin for FakePersistentPlugin {
fn identity(&self) -> &PluginIdentity {
&self.identity
}
fn is_running(&self) -> bool {
true
}
fn pid(&self) -> Option<u32> {
None
}
fn set_gc_config(&self, _gc_config: &PluginGcConfig) {
// We don't have a GC
}
fn stop(&self) -> Result<(), ShellError> {
// We can't stop
Ok(())
}
fn as_any(self: Arc<Self>) -> Arc<dyn Any + Send + Sync> {
self
}
}
impl GetPlugin for FakePersistentPlugin {
fn get_plugin(
self: Arc<Self>,
_context: Option<(&EngineState, &mut Stack)>,
) -> Result<PluginInterface, ShellError> {
self.plugin
.get()
.cloned()
.ok_or_else(|| ShellError::PluginFailedToLoad {
msg: "FakePersistentPlugin was not initialized".into(),
})
}
}

View File

@ -0,0 +1,27 @@
use std::sync::Arc;
use nu_plugin::{Plugin, PluginDeclaration};
use nu_protocol::{engine::StateWorkingSet, RegisteredPlugin, ShellError};
use crate::{fake_persistent_plugin::FakePersistentPlugin, spawn_fake_plugin::spawn_fake_plugin};
/// Register all of the commands from the plugin into the [`StateWorkingSet`]
pub fn fake_register(
working_set: &mut StateWorkingSet,
name: &str,
plugin: Arc<impl Plugin + Send + 'static>,
) -> Result<Arc<FakePersistentPlugin>, ShellError> {
let reg_plugin = spawn_fake_plugin(name, plugin.clone())?;
let reg_plugin_clone = reg_plugin.clone();
for command in plugin.commands() {
let signature = command.signature();
let decl = PluginDeclaration::new(reg_plugin.clone(), signature);
working_set.add_decl(Box::new(decl));
}
let identity = reg_plugin.identity().clone();
working_set.find_or_create_plugin(&identity, move || reg_plugin);
Ok(reg_plugin_clone)
}

View File

@ -0,0 +1,71 @@
//! Test support for [Nushell](https://nushell.sh) plugins.
//!
//! # Example
//!
//! ```rust
//! use std::sync::Arc;
//!
//! use nu_plugin::*;
//! use nu_plugin_test_support::PluginTest;
//! use nu_protocol::{PluginSignature, PipelineData, Type, Span, Value, LabeledError};
//! use nu_protocol::IntoInterruptiblePipelineData;
//!
//! struct LowercasePlugin;
//! struct Lowercase;
//!
//! impl PluginCommand for Lowercase {
//! type Plugin = LowercasePlugin;
//!
//! fn signature(&self) -> PluginSignature {
//! PluginSignature::build("lowercase")
//! .usage("Convert each string in a stream to lowercase")
//! .input_output_type(Type::List(Type::String.into()), Type::List(Type::String.into()))
//! }
//!
//! fn run(
//! &self,
//! plugin: &LowercasePlugin,
//! engine: &EngineInterface,
//! call: &EvaluatedCall,
//! input: PipelineData,
//! ) -> Result<PipelineData, LabeledError> {
//! let span = call.head;
//! Ok(input.map(move |value| {
//! value.as_str()
//! .map(|string| Value::string(string.to_lowercase(), span))
//! // Errors in a stream should be returned as values.
//! .unwrap_or_else(|err| Value::error(err, span))
//! }, None)?)
//! }
//! }
//!
//! impl Plugin for LowercasePlugin {
//! fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin=Self>>> {
//! vec![Box::new(Lowercase)]
//! }
//! }
//!
//! fn test_lowercase() -> Result<(), LabeledError> {
//! let input = vec![Value::test_string("FooBar")].into_pipeline_data(None);
//! let output = PluginTest::new("lowercase", LowercasePlugin.into())?
//! .eval_with("lowercase", input)?
//! .into_value(Span::test_data());
//!
//! assert_eq!(
//! Value::test_list(vec![
//! Value::test_string("foobar")
//! ]),
//! output
//! );
//! Ok(())
//! }
//! #
//! # test_lowercase().unwrap();
//! ```
mod fake_persistent_plugin;
mod fake_register;
mod plugin_test;
mod spawn_fake_plugin;
pub use plugin_test::PluginTest;

View File

@ -0,0 +1,264 @@
use std::{convert::Infallible, sync::Arc};
use difference::Changeset;
use nu_engine::eval_block;
use nu_parser::parse;
use nu_plugin::{Plugin, PluginCommand, PluginCustomValue, PluginSource};
use nu_protocol::{
debugger::WithoutDebug,
engine::{EngineState, Stack, StateWorkingSet},
report_error_new, LabeledError, PipelineData, PluginExample, ShellError, Span, Value,
};
use crate::fake_register::fake_register;
/// An object through which plugins can be tested.
pub struct PluginTest {
engine_state: EngineState,
source: Arc<PluginSource>,
entry_num: usize,
}
impl PluginTest {
/// Create a new test for the given `plugin` named `name`.
///
/// # Example
///
/// ```rust,no_run
/// # use nu_plugin_test_support::PluginTest;
/// # use nu_protocol::ShellError;
/// # use nu_plugin::*;
/// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<PluginTest, ShellError> {
/// PluginTest::new("my_plugin", MyPlugin.into())
/// # }
/// ```
pub fn new(
name: &str,
plugin: Arc<impl Plugin + Send + 'static>,
) -> Result<PluginTest, ShellError> {
let mut engine_state = EngineState::new();
let mut working_set = StateWorkingSet::new(&engine_state);
let reg_plugin = fake_register(&mut working_set, name, plugin)?;
let source = Arc::new(PluginSource::new(reg_plugin));
engine_state.merge_delta(working_set.render())?;
Ok(PluginTest {
engine_state,
source,
entry_num: 1,
})
}
/// Get the [`EngineState`].
pub fn engine_state(&self) -> &EngineState {
&self.engine_state
}
/// Get a mutable reference to the [`EngineState`].
pub fn engine_state_mut(&mut self) -> &mut EngineState {
&mut self.engine_state
}
/// Evaluate some Nushell source code with the plugin commands in scope with the given input to
/// the pipeline.
///
/// # Example
///
/// ```rust,no_run
/// # use nu_plugin_test_support::PluginTest;
/// # use nu_protocol::{ShellError, Span, Value, IntoInterruptiblePipelineData};
/// # use nu_plugin::*;
/// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<(), ShellError> {
/// let result = PluginTest::new("my_plugin", MyPlugin.into())?
/// .eval_with(
/// "my-command",
/// vec![Value::test_int(42)].into_pipeline_data(None)
/// )?
/// .into_value(Span::test_data());
/// assert_eq!(Value::test_string("42"), result);
/// # Ok(())
/// # }
/// ```
pub fn eval_with(
&mut self,
nu_source: &str,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
let mut working_set = StateWorkingSet::new(&self.engine_state);
let fname = format!("entry #{}", self.entry_num);
self.entry_num += 1;
// Parse the source code
let block = parse(&mut working_set, Some(&fname), nu_source.as_bytes(), false);
// Check for parse errors
let error = if !working_set.parse_errors.is_empty() {
// ShellError doesn't have ParseError, use LabeledError to contain it.
let mut error = LabeledError::new("Example failed to parse");
error.inner.extend(
working_set
.parse_errors
.iter()
.map(LabeledError::from_diagnostic),
);
Some(ShellError::LabeledError(error.into()))
} else {
None
};
// Merge into state
self.engine_state.merge_delta(working_set.render())?;
// Return error if set. We merge the delta even if we have errors so that printing the error
// based on the engine state still works.
if let Some(error) = error {
return Err(error);
}
// Serialize custom values in the input
let source = self.source.clone();
let input = input.map(
move |mut value| match PluginCustomValue::serialize_custom_values_in(&mut value) {
Ok(()) => {
// Make sure to mark them with the source so they pass correctly, too.
PluginCustomValue::add_source(&mut value, &source);
value
}
Err(err) => Value::error(err, value.span()),
},
None,
)?;
// Eval the block with the input
let mut stack = Stack::new().capture();
eval_block::<WithoutDebug>(&self.engine_state, &mut stack, &block, input)?.map(
|mut value| {
// Make sure to deserialize custom values
match PluginCustomValue::deserialize_custom_values_in(&mut value) {
Ok(()) => value,
Err(err) => Value::error(err, value.span()),
}
},
None,
)
}
/// Evaluate some Nushell source code with the plugin commands in scope.
///
/// # Example
///
/// ```rust,no_run
/// # use nu_plugin_test_support::PluginTest;
/// # use nu_protocol::{ShellError, Span, Value, IntoInterruptiblePipelineData};
/// # use nu_plugin::*;
/// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<(), ShellError> {
/// let result = PluginTest::new("my_plugin", MyPlugin.into())?
/// .eval("42 | my-command")?
/// .into_value(Span::test_data());
/// assert_eq!(Value::test_string("42"), result);
/// # Ok(())
/// # }
/// ```
pub fn eval(&mut self, nu_source: &str) -> Result<PipelineData, ShellError> {
self.eval_with(nu_source, PipelineData::Empty)
}
/// Test a list of plugin examples. Prints an error for each failing example.
///
/// See [`.test_command_examples()`] for easier usage of this method on a command's examples.
///
/// # Example
///
/// ```rust,no_run
/// # use nu_plugin_test_support::PluginTest;
/// # use nu_protocol::{ShellError, PluginExample, Value};
/// # use nu_plugin::*;
/// # fn test(MyPlugin: impl Plugin + Send + 'static) -> Result<(), ShellError> {
/// PluginTest::new("my_plugin", MyPlugin.into())?
/// .test_examples(&[
/// PluginExample {
/// example: "my-command".into(),
/// description: "Run my-command".into(),
/// result: Some(Value::test_string("my-command output")),
/// },
/// ])
/// # }
/// ```
pub fn test_examples(&mut self, examples: &[PluginExample]) -> Result<(), ShellError> {
let mut failed = false;
for example in examples {
let mut failed_header = || {
failed = true;
eprintln!("\x1b[1mExample:\x1b[0m {}", example.example);
eprintln!("\x1b[1mDescription:\x1b[0m {}", example.description);
};
if let Some(expectation) = &example.result {
match self.eval(&example.example) {
Ok(data) => {
let mut value = data.into_value(Span::test_data());
// Set all of the spans in the value to test_data() to avoid unnecessary
// differences when printing
let _: Result<(), Infallible> = value.recurse_mut(&mut |here| {
here.set_span(Span::test_data());
Ok(())
});
// Check for equality with the result
if *expectation != value {
// If they're not equal, print a diff of the debug format
let expectation_formatted = format!("{:#?}", expectation);
let value_formatted = format!("{:#?}", value);
let diff =
Changeset::new(&expectation_formatted, &value_formatted, "\n");
failed_header();
eprintln!("\x1b[1mResult:\x1b[0m {diff}");
} else {
println!("{:?}, {:?}", expectation, value);
}
}
Err(err) => {
// Report the error
failed_header();
report_error_new(&self.engine_state, &err);
}
}
}
}
if !failed {
Ok(())
} else {
Err(ShellError::GenericError {
error: "Some examples failed. See the error output for details".into(),
msg: "".into(),
span: None,
help: None,
inner: vec![],
})
}
}
/// Test examples from a command.
///
/// # Example
///
/// ```rust,no_run
/// # use nu_plugin_test_support::PluginTest;
/// # use nu_protocol::ShellError;
/// # use nu_plugin::*;
/// # fn test(MyPlugin: impl Plugin + Send + 'static, MyCommand: impl PluginCommand) -> Result<(), ShellError> {
/// PluginTest::new("my_plugin", MyPlugin.into())?
/// .test_command_examples(&MyCommand)
/// # }
/// ```
pub fn test_command_examples(
&mut self,
command: &impl PluginCommand,
) -> Result<(), ShellError> {
self.test_examples(&command.signature().examples)
}
}

View File

@ -0,0 +1,77 @@
use std::sync::{mpsc, Arc};
use nu_plugin::{
InterfaceManager, Plugin, PluginInput, PluginInterfaceManager, PluginOutput, PluginRead,
PluginSource, PluginWrite,
};
use nu_protocol::{PluginIdentity, ShellError};
use crate::fake_persistent_plugin::FakePersistentPlugin;
struct FakePluginRead<T>(mpsc::Receiver<T>);
struct FakePluginWrite<T>(mpsc::Sender<T>);
impl<T> PluginRead<T> for FakePluginRead<T> {
fn read(&mut self) -> Result<Option<T>, ShellError> {
Ok(self.0.recv().ok())
}
}
impl<T: Clone + Send> PluginWrite<T> for FakePluginWrite<T> {
fn write(&self, data: &T) -> Result<(), ShellError> {
self.0
.send(data.clone())
.map_err(|err| ShellError::IOError {
msg: err.to_string(),
})
}
fn flush(&self) -> Result<(), ShellError> {
Ok(())
}
}
fn fake_plugin_channel<T: Clone + Send>() -> (FakePluginRead<T>, FakePluginWrite<T>) {
let (tx, rx) = mpsc::channel();
(FakePluginRead(rx), FakePluginWrite(tx))
}
/// Spawn a plugin on another thread and return the registration
pub(crate) fn spawn_fake_plugin(
name: &str,
plugin: Arc<impl Plugin + Send + 'static>,
) -> Result<Arc<FakePersistentPlugin>, ShellError> {
let (input_read, input_write) = fake_plugin_channel::<PluginInput>();
let (output_read, output_write) = fake_plugin_channel::<PluginOutput>();
let identity = PluginIdentity::new_fake(name);
let reg_plugin = Arc::new(FakePersistentPlugin::new(identity.clone()));
let source = Arc::new(PluginSource::new(reg_plugin.clone()));
let mut manager = PluginInterfaceManager::new(source, input_write);
// Set up the persistent plugin with the interface before continuing
let interface = manager.get_interface();
interface.hello()?;
reg_plugin.initialize(interface);
// Start the interface reader on another thread
std::thread::Builder::new()
.name(format!("fake plugin interface reader ({name})"))
.spawn(move || manager.consume_all(output_read).expect("Plugin read error"))?;
// Start the plugin on another thread
let name_string = name.to_owned();
std::thread::Builder::new()
.name(format!("fake plugin runner ({name})"))
.spawn(move || {
nu_plugin::serve_plugin_io(
&*plugin,
&name_string,
move || input_read,
move || output_write,
)
.expect("Plugin runner error")
})?;
Ok(reg_plugin)
}