pinakes-core: add plugin integration tests and test fixtures
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If4372ea33b93306486170353f9edf4a76a6a6964
This commit is contained in:
parent
61ebc6824c
commit
7d3c2052c2
6 changed files with 404 additions and 2 deletions
2
crates/pinakes-core/tests/fixtures/test-plugin/.gitignore
vendored
Normal file
2
crates/pinakes-core/tests/fixtures/test-plugin/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target/
|
||||
Cargo.lock
|
||||
14
crates/pinakes-core/tests/fixtures/test-plugin/Cargo.toml
vendored
Normal file
14
crates/pinakes-core/tests/fixtures/test-plugin/Cargo.toml
vendored
Normal file
|
|
@ -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
|
||||
17
crates/pinakes-core/tests/fixtures/test-plugin/plugin.toml
vendored
Normal file
17
crates/pinakes-core/tests/fixtures/test-plugin/plugin.toml
vendored
Normal file
|
|
@ -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"]
|
||||
181
crates/pinakes-core/tests/fixtures/test-plugin/src/lib.rs
vendored
Normal file
181
crates/pinakes-core/tests/fixtures/test-plugin/src/lib.rs
vendored
Normal file
|
|
@ -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<u8> {
|
||||
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"{}");
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
188
crates/pinakes-core/tests/plugin_integration.rs
Normal file
188
crates/pinakes-core/tests/plugin_integration.rs
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue