mirror of
https://github.com/nushell/nushell.git
synced 2025-08-09 15:44:59 +02:00
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:
71
crates/nu-plugin-test-support/src/fake_persistent_plugin.rs
Normal file
71
crates/nu-plugin-test-support/src/fake_persistent_plugin.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
27
crates/nu-plugin-test-support/src/fake_register.rs
Normal file
27
crates/nu-plugin-test-support/src/fake_register.rs
Normal 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)
|
||||
}
|
71
crates/nu-plugin-test-support/src/lib.rs
Normal file
71
crates/nu-plugin-test-support/src/lib.rs
Normal 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;
|
264
crates/nu-plugin-test-support/src/plugin_test.rs
Normal file
264
crates/nu-plugin-test-support/src/plugin_test.rs
Normal 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)
|
||||
}
|
||||
}
|
77
crates/nu-plugin-test-support/src/spawn_fake_plugin.rs
Normal file
77
crates/nu-plugin-test-support/src/spawn_fake_plugin.rs
Normal 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)
|
||||
}
|
Reference in New Issue
Block a user