use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::Value as JsonValue;
use diaryx_core::fs::{RealFileSystem, SyncToAsyncFs};
use diaryx_core::plugin::permissions::PermissionType;
use diaryx_core::plugin::{
FileCreatedEvent, FileDeletedEvent, FileMovedEvent, FilePlugin, FileSavedEvent, Plugin,
PluginContext, PluginError, PluginId, PluginManifest, WorkspaceOpenedEvent, WorkspacePlugin,
};
use crate::host_fns::*;
use crate::loader::load_plugin_from_wasm;
pub struct RecordingEventEmitter {
events: Mutex<Vec<String>>,
}
impl RecordingEventEmitter {
pub fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
pub fn events(&self) -> Vec<String> {
self.events.lock().unwrap().clone()
}
pub fn events_json(&self) -> Vec<JsonValue> {
self.events()
.into_iter()
.filter_map(|s| serde_json::from_str(&s).ok())
.collect()
}
pub fn clear(&self) {
self.events.lock().unwrap().clear();
}
}
impl Default for RecordingEventEmitter {
fn default() -> Self {
Self::new()
}
}
impl EventEmitter for RecordingEventEmitter {
fn emit(&self, event_json: &str) {
self.events.lock().unwrap().push(event_json.to_string());
}
}
#[derive(Debug, Clone)]
pub enum StorageOp {
Get(String),
Set(String, Vec<u8>),
Delete(String),
}
pub struct RecordingStorage {
data: Mutex<HashMap<String, Vec<u8>>>,
ops: Mutex<Vec<StorageOp>>,
}
impl RecordingStorage {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
ops: Mutex::new(Vec::new()),
}
}
pub fn with_data(self, key: &str, value: &[u8]) -> Self {
self.data
.lock()
.unwrap()
.insert(key.to_string(), value.to_vec());
self
}
pub fn ops(&self) -> Vec<StorageOp> {
self.ops.lock().unwrap().clone()
}
}
impl Default for RecordingStorage {
fn default() -> Self {
Self::new()
}
}
impl PluginStorage for RecordingStorage {
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.ops
.lock()
.unwrap()
.push(StorageOp::Get(key.to_string()));
self.data.lock().unwrap().get(key).cloned()
}
fn set(&self, key: &str, data: &[u8]) {
self.ops
.lock()
.unwrap()
.push(StorageOp::Set(key.to_string(), data.to_vec()));
self.data
.lock()
.unwrap()
.insert(key.to_string(), data.to_vec());
}
fn delete(&self, key: &str) {
self.ops
.lock()
.unwrap()
.push(StorageOp::Delete(key.to_string()));
self.data.lock().unwrap().remove(key);
}
}
pub struct AllowAllPermissionChecker;
impl PermissionChecker for AllowAllPermissionChecker {
fn check_permission(
&self,
_plugin_id: &str,
_permission_type: PermissionType,
_target: &str,
) -> Result<(), String> {
Ok(())
}
}
pub struct PluginTestHarnessBuilder {
wasm_path: PathBuf,
storage: Option<Arc<dyn PluginStorage>>,
event_emitter: Option<Arc<dyn EventEmitter>>,
permission_checker: Option<Arc<dyn PermissionChecker>>,
workspace_root: Option<PathBuf>,
}
impl PluginTestHarnessBuilder {
pub fn new(wasm_path: impl Into<PathBuf>) -> Self {
Self {
wasm_path: wasm_path.into(),
storage: None,
event_emitter: None,
permission_checker: None,
workspace_root: None,
}
}
pub fn with_storage(mut self, storage: Arc<dyn PluginStorage>) -> Self {
self.storage = Some(storage);
self
}
pub fn with_event_emitter(mut self, emitter: Arc<dyn EventEmitter>) -> Self {
self.event_emitter = Some(emitter);
self
}
pub fn with_permission_checker(mut self, checker: Arc<dyn PermissionChecker>) -> Self {
self.permission_checker = Some(checker);
self
}
pub fn with_workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
self.workspace_root = Some(root.into());
self
}
pub fn build(self) -> Result<PluginTestHarness, String> {
let fs = Arc::new(SyncToAsyncFs::new(RealFileSystem));
let host_context = Arc::new(HostContext {
fs,
storage: self.storage.unwrap_or_else(|| Arc::new(NoopStorage)),
secret_store: Arc::new(NoopSecretStore),
event_emitter: self
.event_emitter
.unwrap_or_else(|| Arc::new(NoopEventEmitter)),
plugin_id: String::new(),
plugin_id_locked: false,
permission_checker: Some(
self.permission_checker
.unwrap_or_else(|| Arc::new(AllowAllPermissionChecker)),
),
file_provider: Arc::new(NoopFileProvider),
ws_bridge: Arc::new(NoopWebSocketBridge),
plugin_command_bridge: Arc::new(NoopPluginCommandBridge),
runtime_context_provider: Arc::new(NoopRuntimeContextProvider),
namespace_provider: Arc::new(crate::host_fns::NoopNamespaceProvider),
plugin_command_depth: 0,
storage_quota_bytes: crate::host_fns::DEFAULT_STORAGE_QUOTA_BYTES,
});
let adapter = load_plugin_from_wasm(&self.wasm_path, host_context, None)
.map_err(|e| format!("Failed to load plugin: {e}"))?;
Ok(PluginTestHarness {
adapter: Arc::new(adapter),
workspace_root: self.workspace_root,
})
}
}
pub struct PluginTestHarness {
adapter: Arc<crate::adapter::ExtismPluginAdapter>,
workspace_root: Option<PathBuf>,
}
impl PluginTestHarness {
pub fn load(wasm_path: impl Into<PathBuf>) -> Result<Self, String> {
PluginTestHarnessBuilder::new(wasm_path).build()
}
pub fn manifest(&self) -> PluginManifest {
self.adapter.manifest()
}
pub fn plugin_id(&self) -> PluginId {
self.adapter.id()
}
pub async fn init(&self) -> Result<(), PluginError> {
let ctx = PluginContext::new(
self.workspace_root.clone(),
diaryx_core::link_parser::LinkFormat::default(),
);
self.adapter.init(&ctx).await
}
pub async fn command(
&self,
cmd: &str,
params: JsonValue,
) -> Option<Result<JsonValue, PluginError>> {
self.adapter.handle_command(cmd, params).await
}
pub async fn send_workspace_opened(&self, workspace_root: PathBuf) {
self.adapter
.on_workspace_opened(&WorkspaceOpenedEvent { workspace_root })
.await;
}
pub async fn send_file_saved(&self, path: &str) {
self.adapter
.on_file_saved(&FileSavedEvent {
path: path.to_string(),
})
.await;
}
pub async fn send_file_created(&self, path: &str) {
self.adapter
.on_file_created(&FileCreatedEvent {
path: path.to_string(),
})
.await;
}
pub async fn send_file_deleted(&self, path: &str) {
self.adapter
.on_file_deleted(&FileDeletedEvent {
path: path.to_string(),
})
.await;
}
pub async fn send_file_moved(&self, old_path: &str, new_path: &str) {
self.adapter
.on_file_moved(&FileMovedEvent {
old_path: old_path.to_string(),
new_path: new_path.to_string(),
})
.await;
}
pub async fn get_config(&self) -> Option<JsonValue> {
self.adapter.get_config().await
}
pub async fn set_config(&self, config: JsonValue) -> Result<(), PluginError> {
self.adapter.set_config(config).await
}
pub fn call_raw(&self, func: &str, input: &str) -> Result<String, PluginError> {
self.adapter.call_guest(func, input)
}
}