//! The first first-party plugin: the dice module, loaded from disk exactly as //! a user plugin is (SPEC 7.2, 7.4). //! //! The build is not part of the test: run `plugins/dice/build.sh` first. The //! tests are `#[ignore]`d, so a checkout without the module keeps the suite //! green, and they print why they stop when the module is missing. mod common; use std::path::{Path, PathBuf}; use common::{seed, temp_vault}; use notescraft_engine::{AppSettings, Buffer, Registry, Session, register_core}; use notescraft_plugins::Permission; use serde_json::{Value, json}; /// The folder of the plugin, relative to the crate. fn plugin_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/dice") } /// The module and the manifest of the plugin, or `None` when it was not built. fn built() -> Option<(PathBuf, PathBuf)> { let module = plugin_dir().join("dice.wasm"); let manifest = plugin_dir().join("manifest.json"); (module.exists() && manifest.exists()).then_some((module, manifest)) } /// The settings that enable the plugin and approve the `editor` permission. fn settings() -> AppSettings { let mut settings = AppSettings::default(); settings.plugins.enabled.insert("dice".to_owned()); settings.plugins.approved.insert("dice".to_owned(), vec![Permission::Editor]); settings } /// Runs `body` in a session whose vault holds the dice plugin. fn with_dice(body: impl FnOnce(&mut Session) -> T) -> Option { let Some((module, manifest)) = built() else { println!("skipped: {} is missing; run plugins/dice/build.sh", plugin_dir().join("dice.wasm").display()); return None; }; let vault = temp_vault("dice"); seed(vault.path(), &[("note.md", "roll: ")]); let folder = vault.path().join(".notescraft/plugins/dice"); std::fs::create_dir_all(&folder).expect("plugin folder"); std::fs::copy(&module, folder.join("plugin.wasm")).expect("copy the module"); std::fs::copy(&manifest, folder.join("manifest.json")).expect("copy the manifest"); let mut session = Session::open(vault.path()).expect("session"); session.apply_app_settings(&settings()).expect("settings"); session.load_plugins().expect("load plugins"); Some(body(&mut session)) } #[test] #[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"] fn the_plugin_loads_and_rolls() { let Some(result) = with_dice(|session| { // The host refuses a module whose `ns_abi_version` is not 1 and every // missing export, so an empty diagnostics list is the ABI check. assert!(session.diagnostics().is_empty(), "{:?}", session.diagnostics()); let infos = session.plugin_infos(); assert_eq!(infos.len(), 1); assert_eq!(infos.first().map(|info| info.id.as_str()), Some("dice")); // The manifest of the plugin is the one that ships: it declares `roll`. let mut registry = Registry::new(); register_core(&mut registry).expect("core commands"); assert_eq!(session.register_plugin_commands(&mut registry).expect("register"), 1); assert!(registry.get("plugin.dice.roll").is_some()); session.run_plugin_command("plugin.dice.roll", r#"{"expr":"1d1"}"#).expect("the plugin rolls") }) else { return; }; let value: Value = serde_json::from_str(&result).expect("the answer is JSON"); assert_eq!(value.get("total"), Some(&json!(1))); assert_eq!(value.get("rolls"), Some(&json!([1]))); assert_eq!(value.get("text"), Some(&json!("1d1 = 1 [1]"))); } #[test] #[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"] fn the_roll_is_inserted_into_the_focused_note() { let Some(text) = with_dice(|session| { session.open_buffer("note.md").expect("buffer"); session.focus_note("note.md").expect("focus"); let result = session.run_plugin_command("plugin.dice.roll", r#"{"expr":"2d6+3"}"#).expect("the plugin rolls"); let value: Value = serde_json::from_str(&result).expect("the answer is JSON"); let text = value.get("text").and_then(Value::as_str).expect("the answer has a text").to_owned(); let buffer = session.buffer("note.md").map(Buffer::text).expect("the buffer is open").to_owned(); assert_eq!(buffer, format!("roll: {text}"), "the host inserted the text"); assert_eq!(buffer.matches(&text).count(), 1, "the text is inserted once"); text }) else { return; }; assert!(text.starts_with("2d6+3 = "), "{text}"); assert!(text.ends_with(" +3"), "{text}"); } #[test] #[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"] fn a_hundred_dice_stay_inside_the_fuel_budget() { let Some(result) = with_dice(|session| { // The default budget is 50 million fuel; a hundred dice must not come // close to it, so the call has to answer instead of hitting the limit. session.run_plugin_command("plugin.dice.roll", r#"{"expr":"100d1000"}"#).expect("the plugin rolls") }) else { return; }; let value: Value = serde_json::from_str(&result).expect("the answer is JSON"); let rolls = value.get("rolls").and_then(Value::as_array).expect("the answer has the rolls"); assert_eq!(rolls.len(), 100); let mut sum = 0u64; for die in rolls { let sides = die.as_u64().expect("a die is a number"); assert!((1..=1000).contains(&sides), "{sides}"); sum = sum.saturating_add(sides); } let total = value.get("total").and_then(Value::as_i64).expect("the answer has the total"); assert_eq!(total, i64::try_from(sum).expect("the sum fits")); } #[test] #[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"] fn an_expression_out_of_bounds_is_a_guest_error() { // Three refusals are the most a plugin may collect before the host // disables it, so this session runs exactly three. let Some(errors) = with_dice(|session| { let mut errors = Vec::new(); for expr in ["0d6", "101d6", "1d1001"] { let args = format!(r#"{{"expr":"{expr}"}}"#); let error = session.run_plugin_command("plugin.dice.roll", &args).expect_err("the expression is refused"); errors.push(error.to_string()); } errors }) else { return; }; for error in &errors { assert!(error.contains("error code"), "{error}"); assert!(!error.contains("trap"), "{error}"); } } #[test] #[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"] fn a_kept_expression_is_a_guest_error() { let Some(error) = with_dice(|session| { let error = session.run_plugin_command("plugin.dice.roll", r#"{"expr":"2d6kh3"}"#).expect_err("kh is not supported"); error.to_string() }) else { return; }; assert!(error.contains("error code"), "{error}"); assert!(!error.contains("trap"), "{error}"); }