diff --git a/crates/pinakes-core/tests/fixtures/test-plugin/.gitignore b/crates/pinakes-core/tests/fixtures/test-plugin/.gitignore new file mode 100644 index 0000000..ca98cd9 --- /dev/null +++ b/crates/pinakes-core/tests/fixtures/test-plugin/.gitignore @@ -0,0 +1,2 @@ +/target/ +Cargo.lock diff --git a/crates/pinakes-core/tests/fixtures/test-plugin/Cargo.toml b/crates/pinakes-core/tests/fixtures/test-plugin/Cargo.toml new file mode 100644 index 0000000..eeb8116 --- /dev/null +++ b/crates/pinakes-core/tests/fixtures/test-plugin/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "test-plugin" +version = "1.0.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2", features = ["global"] } + +[profile.release] +opt-level = "s" +lto = true diff --git a/crates/pinakes-core/tests/fixtures/test-plugin/plugin.toml b/crates/pinakes-core/tests/fixtures/test-plugin/plugin.toml new file mode 100644 index 0000000..a9c9e5f --- /dev/null +++ b/crates/pinakes-core/tests/fixtures/test-plugin/plugin.toml @@ -0,0 +1,17 @@ +[plugin] +name = "test-plugin" +version = "1.0.0" +api_version = "1.0" +description = "Test fixture plugin for integration tests" +kind = ["media_type", "metadata_extractor", "thumbnail_generator", "event_handler"] +priority = 50 + +[plugin.binary] +wasm = "test_plugin.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = ["/tmp"] +write = ["/tmp"] diff --git a/crates/pinakes-core/tests/fixtures/test-plugin/src/lib.rs b/crates/pinakes-core/tests/fixtures/test-plugin/src/lib.rs new file mode 100644 index 0000000..5ebfa3c --- /dev/null +++ b/crates/pinakes-core/tests/fixtures/test-plugin/src/lib.rs @@ -0,0 +1,181 @@ +//! Test fixture plugin for integration tests. +//! +//! Exercises all extension points: MediaTypeProvider, MetadataExtractor, +//! ThumbnailGenerator, and EventHandler. +//! +//! To build in the project directory, you might need to set `RUSTFLAGS=""` or +//! explicitly specify a non-Clang linking pipeline. Below command is the +//! easiest way to build this: +//! +//! ``` +//! $ RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release` +//! ``` + +#![no_std] + +extern crate alloc; + +use alloc::{format, vec::Vec}; +use core::alloc::Layout; + +// Global allocator for no_std WASM +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic_handler(_info: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the runtime +unsafe extern "C" { + fn host_set_result(ptr: i32, len: i32); + fn host_log(level: i32, ptr: i32, len: i32); +} + +/// Write a JSON response back to the host via host_set_result. +fn set_response(json: &[u8]) { + unsafe { + host_set_result(json.as_ptr() as i32, json.len() as i32); + } +} + +/// Log at info level. +fn log_info(msg: &str) { + unsafe { + host_log(2, msg.as_ptr() as i32, msg.len() as i32); + } +} + +/// Read the JSON request bytes from memory at (ptr, len). +unsafe fn read_request(ptr: i32, len: i32) -> Vec { + if ptr < 0 || len <= 0 { + return Vec::new(); + } + let slice = + unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; + slice.to_vec() +} + +/// JSON string value extraction, without Serde. +/// Finds `"key": "value"` and returns `value`. +/// FIXME: this is a hack, I need to look into serde without std... +fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { + let json_str = core::str::from_utf8(json).ok()?; + let pattern = format!("\"{}\"", key); + let key_pos = json_str.find(&pattern)?; + let after_key = &json_str[key_pos + pattern.len()..]; + // Skip `: ` or `:` + let after_colon = after_key.trim_start().strip_prefix(':')?; + let after_colon = after_colon.trim_start(); + + if after_colon.starts_with('"') { + let value_start = 1; + let value_end = after_colon[value_start..].find('"')?; + Some(&after_colon[value_start..value_start + value_end]) + } else { + None + } +} + +/// Allocate memory for the host to write request data into. +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = Layout::from_size_align(size as usize, 1).unwrap(); + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { + return -1; + } + ptr as i32 + } +} + +// Lifecycle +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info("test-plugin initialized"); + 0 +} + +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info("test-plugin shutdown"); + 0 +} + +/// Returns custom media type definitions: a `.testfile` type. +#[unsafe(no_mangle)] +pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { + let response = br#"[{"id":"testfile","name":"Test File","category":"document","extensions":["testfile"],"mime_types":["application/x-testfile"]}]"#; + set_response(response); +} + +/// Check if this plugin can handle a given file path. +#[unsafe(no_mangle)] +pub extern "C" fn can_handle(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + + let path = json_get_str(&req, "path").unwrap_or(""); + let can = path.ends_with(".testfile"); + if can { + set_response(br#"{"can_handle":true}"#); + } else { + set_response(br#"{"can_handle":false}"#); + } +} + +/// Returns the list of media types this extractor supports. +#[unsafe(no_mangle)] +pub extern "C" fn supported_types(_ptr: i32, _len: i32) { + set_response(br#"["testfile"]"#); +} + +/// Extract metadata from a file. +#[unsafe(no_mangle)] +pub extern "C" fn extract_metadata(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let path = json_get_str(&req, "path").unwrap_or("unknown"); + + // Extract filename from path + let filename = path.rsplit('/').next().unwrap_or(path); + + let response = format!( + r#"{{"title":"{}","artist":"test-plugin","description":"Extracted by test-plugin","extra":{{"plugin_source":"test-plugin"}}}}"#, + filename, + ); + set_response(response.as_bytes()); +} + +/// Generate a thumbnail (returns a fixed path for testing). +#[unsafe(no_mangle)] +pub extern "C" fn generate_thumbnail(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let output = + json_get_str(&req, "output_path").unwrap_or("/tmp/test-thumb.jpg"); + + let response = format!( + r#"{{"path":"{}","width":320,"height":320,"format":"jpeg"}}"#, + output + ); + set_response(response.as_bytes()); +} + +/// Returns the event types this handler is interested in. +#[unsafe(no_mangle)] +pub extern "C" fn interested_events(_ptr: i32, _len: i32) { + set_response(br#"["MediaImported","MediaUpdated","MediaDeleted"]"#); +} + +/// Handle an event. +#[unsafe(no_mangle)] +pub extern "C" fn handle_event(ptr: i32, len: i32) { + let req = unsafe { read_request(ptr, len) }; + let event_type = json_get_str(&req, "event_type").unwrap_or("unknown"); + let msg = format!("test-plugin handled event: {}", event_type); + log_info(&msg); + set_response(b"{}"); +} diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index 161755f..da1035a 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -407,13 +407,13 @@ async fn test_import_with_dedup() { std::fs::write(&file_path, "hello world").unwrap(); // First import - let result1 = pinakes_core::import::import_file(&storage, &file_path) + let result1 = pinakes_core::import::import_file(&storage, &file_path, None) .await .unwrap(); assert!(!result1.was_duplicate); // Second import of same file - let result2 = pinakes_core::import::import_file(&storage, &file_path) + let result2 = pinakes_core::import::import_file(&storage, &file_path, None) .await .unwrap(); assert!(result2.was_duplicate); diff --git a/crates/pinakes-core/tests/plugin_integration.rs b/crates/pinakes-core/tests/plugin_integration.rs new file mode 100644 index 0000000..c48a250 --- /dev/null +++ b/crates/pinakes-core/tests/plugin_integration.rs @@ -0,0 +1,188 @@ +//! Integration tests for the plugin pipeline with real WASM execution. +//! +//! These tests use the test-plugin fixture at +//! `tests/fixtures/test-plugin/` which is a compiled WASM binary +//! exercising all extension points. + +// FIXME: add a Justfile and make sure the fixture is compiled for tests... + +#![allow(clippy::print_stderr, reason = "Fine for tests")] +use std::{path::Path, sync::Arc}; + +use pinakes_core::{ + config::PluginTimeoutConfig, + plugin::{PluginManager, PluginManagerConfig, PluginPipeline}, +}; +use tempfile::TempDir; + +/// Path to the compiled test plugin fixture. +fn fixture_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test-plugin") +} + +/// Check the WASM binary exists (skip tests if not built). +fn wasm_binary_exists() -> bool { + fixture_dir().join("test_plugin.wasm").exists() +} + +/// Set up a `PluginManager` pointing at the fixture directory as a +/// `plugin_dir`. The loader expects `plugin_dirs/test-plugin/plugin.toml`. +/// So we point at the fixtures directory (parent of test-plugin/). +fn setup_manager(temp: &TempDir) -> PluginManager { + let fixtures_dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + + let config = PluginManagerConfig { + plugin_dirs: vec![fixtures_dir], + allow_unsigned: true, + max_concurrent_ops: 2, + plugin_timeout_secs: 30, + timeouts: PluginTimeoutConfig::default(), + max_consecutive_failures: 5, + ..Default::default() + }; + + PluginManager::new( + temp.path().join("data"), + temp.path().join("cache"), + config, + ) + .expect("create plugin manager") +} + +#[tokio::test] +async fn test_plugin_discovery_loads_fixture() { + if !wasm_binary_exists() { + eprintln!("WASM binary not found, skipping test"); + return; + } + + let temp = TempDir::new().unwrap(); + let manager = setup_manager(&temp); + + let loaded = manager.discover_and_load_all().await.unwrap(); + assert!( + !loaded.is_empty(), + "should discover at least the test-plugin" + ); + assert!( + loaded.contains(&"test-plugin".to_string()), + "should load test-plugin, got: {loaded:?}" + ); +} + +#[tokio::test] +async fn test_plugin_capability_discovery() { + if !wasm_binary_exists() { + return; + } + + let temp = TempDir::new().unwrap(); + let manager = Arc::new(setup_manager(&temp)); + manager.discover_and_load_all().await.unwrap(); + + let pipeline = Arc::new(PluginPipeline::new( + manager, + PluginTimeoutConfig::default(), + 5, + )); + + pipeline.discover_capabilities().await.unwrap(); + + // The test plugin registers a custom .testfile media type, so + // resolve_media_type should recognise it (even if no physical file exists, + // can_handle checks the extension). + let resolved = pipeline + .resolve_media_type(Path::new("/tmp/example.testfile")) + .await; + assert!( + resolved.is_some(), + "pipeline should resolve .testfile via test-plugin" + ); +} + +#[tokio::test] +async fn test_pipeline_builtin_fallback_still_works() { + if !wasm_binary_exists() { + return; + } + + let temp = TempDir::new().unwrap(); + let manager = Arc::new(setup_manager(&temp)); + manager.discover_and_load_all().await.unwrap(); + + let pipeline = Arc::new(PluginPipeline::new( + manager, + PluginTimeoutConfig::default(), + 5, + )); + pipeline.discover_capabilities().await.unwrap(); + + // Built-in types should still resolve (test-plugin at priority 50 runs + // first but won't claim .mp3). + let mp3 = pipeline + .resolve_media_type(Path::new("/tmp/song.mp3")) + .await; + assert!(mp3.is_some(), "built-in .mp3 resolution should still work"); + + // Unknown types should still resolve to None + let unknown = pipeline + .resolve_media_type(Path::new("/tmp/data.xyz999")) + .await; + assert!( + unknown.is_none(), + "unknown extension should resolve to None" + ); +} + +#[tokio::test] +async fn test_emit_event_with_loaded_plugin() { + if !wasm_binary_exists() { + return; + } + + let temp = TempDir::new().unwrap(); + let manager = Arc::new(setup_manager(&temp)); + manager.discover_and_load_all().await.unwrap(); + + let pipeline = Arc::new(PluginPipeline::new( + manager, + PluginTimeoutConfig::default(), + 5, + )); + pipeline.discover_capabilities().await.unwrap(); + + // Emit an event the test-plugin is interested in (MediaImported). + // Should not panic; the handler runs in a spawned task. + pipeline.emit_event( + "MediaImported", + &serde_json::json!({"media_id": "test-123", "path": "/tmp/test.mp3"}), + ); + + // Give the spawned task a moment to run + tokio::time::sleep(std::time::Duration::from_millis(200)).await; +} + +#[tokio::test] +async fn test_event_not_interested_is_ignored() { + if !wasm_binary_exists() { + return; + } + + let temp = TempDir::new().unwrap(); + let manager = Arc::new(setup_manager(&temp)); + manager.discover_and_load_all().await.unwrap(); + + let pipeline = Arc::new(PluginPipeline::new( + manager, + PluginTimeoutConfig::default(), + 5, + )); + pipeline.discover_capabilities().await.unwrap(); + + // Emit an event the test-plugin is NOT interested in. + // Should complete silently. + pipeline.emit_event("ScanStarted", &serde_json::json!({"roots": ["/tmp"]})); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; +}