// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
//! Integration tests for the plugin family: the facade exports
//! `oakengine_plugin_*` (src/plugin.rs; module C contract
//! `include/plugin/{host,instance,error}.h`), exercised end to end
//! against the REAL `oakplugin` crate — no mocks anywhere.
//!
//! `oakengine_plugin_load_plugins` drives the real OFX host scan
//! (dlopen of real plugin bundles); the family's only destroy surface
//! lives in the backend module (`oakplugin_instance_create/free`), which
//! is verified here against the module's debug alive counter
//! (`oakplugin_debug_alive_count`, the leak assertion for this family).
//! The two provider setters are pure facade state (module 00 analogues of
//! the C++ capi statics): their result IS the return code, asserted below.
//!
//! The host singleton is process-global and only internally locked, so
//! every test that touches it serializes on [`with_host`] (same
//! convention as the module crate's own tests).
//!
//! The end-to-end bundle test needs the minimal test plugin that the
//! oakplugin crate's build.rs compiles (cbits/oak_test_plugin.c). When
//! it is unavailable the test prints SKIP and returns (never fails).
use super::common;
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::handle::{CHandle, OakEngineNode};
use crate::plugin::{
oakengine_plugin_load_plugins, oakengine_plugin_node_push_button_clicked,
oakengine_plugin_set_active_viewer_provider, oakengine_plugin_set_progress_reporter_factory,
};
// The deleted `oakplugin::ffi` handle surface is replaced by the crate's
// direct host API (single-lib unification).
use oakplugin::handle::RefBox;
use oakplugin::host::Host;
use oakplugin::instance::Instance;
use oakplugin::property::Value;
/// `OAKENGINE_E_INVALID` (src/error.rs).
const E_INVALID: c_int = -1;
/// `OAKENGINE_E_FAILED` (src/error.rs).
const E_FAILED: c_int = -3;
/// `OAKPLUGIN_E_INVALID` (module error.h) — module codes pass through the
/// facade untranslated.
const PLUGIN_E_INVALID: c_int = -90001;
/// Identifier of the minimal OFX test plugin (cbits/oak_test_plugin.c).
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
/// Build-system injected bundle path (set by the CMake test runner).
const TEST_PLUGIN_ENV: &str = "OAK_TEST_PLUGIN_DIR";
// ---------------------------------------------------------------------------
// Host serialization + fixtures
// ---------------------------------------------------------------------------
/// Serialize host-touching tests: the oakplugin host is a process
/// singleton without a top-level lock (each internal list is mutexed, but
/// init/scan/shutdown interleavings would make count assertions flaky).
fn with_host(f: impl FnOnce()) {
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
f();
}
/// Fresh directory under the system temp dir (removed before creation).
fn fresh_temp_dir(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("oak-it-plugin-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).expect("create temp dir");
p
}
/// Current number of live backend objects (host instance registry).
fn alive() -> c_int {
Host::global().alive_count() as c_int
}
/// Number of plugins discovered by the real host cache.
fn plugin_count() -> c_int {
Host::global().cache.count() as c_int
}
/// Run the facade scan through the real host, returning its exit code.
fn scan_facade(dir: &Path) -> c_int {
let cs = CString::new(dir.as_os_str().as_encoded_bytes()).expect("NUL-free path");
unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) }
}
/// The identifier of the plugin at `index` (the direct-API replacement of
/// the deleted `oakplugin_host_plugin_id_at` two-stage getter).
fn host_plugin_id_at(index: usize) -> Option {
Host::global().cache.at(index).map(|p| p.identifier.clone())
}
/// The label of the named plugin (direct-API replacement of the deleted
/// `oakplugin_host_plugin_label` two-stage getter).
fn host_plugin_label(id: &str) -> Option {
let plugin = Host::global().cache.find(id)?;
match plugin.descriptor.props.get("OfxPropLabel", 0) {
Some(Value::String(s)) => Some(s.to_string_lossy().into_owned()),
_ => None,
}
}
/// Create a plugin instance by id (direct-API replacement of the deleted
/// `oakplugin_instance_create`; `None` = the old empty handle).
fn host_instance_create(id: &str) -> Option>> {
Host::global().create_instance(id, None).ok()
}
/// Free a plugin instance (direct-API replacement of the deleted
/// `oakplugin_instance_free`; dropping the Arc is the refcounted free).
fn host_instance_free(inst: &mut Option>>) {
*inst = None;
}
/// Locate the real test plugin shared library: oakplugin's build.rs
/// compiles cbits/oak_test_plugin.c to `$OUT_DIR/oak_test_plugin.{dylib,so}`
/// inside its own `target/*/build/oakplugin-*/out/` directory.
fn find_test_plugin_lib() -> Option {
let ext = if cfg!(target_os = "macos") {
"dylib"
} else {
"so"
};
let mut roots: Vec = Vec::new();
if let Some(t) = std::env::var_os("CARGO_TARGET_DIR") {
roots.push(PathBuf::from(t));
}
roots.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"));
for root in roots {
for profile in ["debug", "release"] {
let build = root.join(profile).join("build");
let Ok(entries) = std::fs::read_dir(&build) else {
continue;
};
let mut hits: Vec = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("oakplugin-"))
})
.collect();
hits.sort();
for dir in hits {
let lib = dir.join("out").join(format!("oak_test_plugin.{ext}"));
if lib.is_file() {
return Some(lib);
}
}
}
}
None
}
/// Copy a test-plugin shared library into a `.bundle` directory layout
/// under `root` (Contents/MacOS or Contents/Linux-x86-64, matching the
/// host's `find_binary_in_bundle`).
fn install_bundle(lib: &Path, root: &Path) -> Option<()> {
let platform = if cfg!(target_os = "macos") {
"MacOS"
} else {
"Linux-x86-64"
};
let bin_dir = root
.join("oak-test-plugin.ofx.bundle")
.join("Contents")
.join(platform);
std::fs::create_dir_all(&bin_dir).ok()?;
std::fs::copy(lib, bin_dir.join("plugin")).ok()?;
Some(())
}
/// A scan directory containing the real test plugin bundle, if available:
/// either the parent of the build-system-injected bundle
/// (`OAK_TEST_PLUGIN_DIR`) or a bundle assembled in the temp dir from the
/// shared library oakplugin's build.rs produced. `None` → caller skips.
fn test_plugin_scan_dir() -> Option {
if let Some(bundle) = std::env::var_os(TEST_PLUGIN_ENV) {
return PathBuf::from(bundle).parent().map(|p| p.to_path_buf());
}
let lib = find_test_plugin_lib()?;
let root = std::env::temp_dir().join(format!("oak-it-plugin-bundle-{}", std::process::id()));
if !root.join("oak-test-plugin.ofx.bundle").exists() {
install_bundle(&lib, &root)?;
}
Some(root)
}
/// A scan directory that is guaranteed to have been scanned by NO other
/// test in this binary (fresh per call), so "scan registers the plugin"
/// assertions are deterministic. The env-injected bundle has no fresh
/// variant and falls back to the shared one.
fn fresh_test_plugin_scan_dir(tag: &str) -> Option {
if std::env::var_os(TEST_PLUGIN_ENV).is_some() {
return test_plugin_scan_dir();
}
let lib = find_test_plugin_lib()?;
let root = fresh_temp_dir(&format!("bundle-{tag}"));
install_bundle(&lib, &root)?;
Some(root)
}
/// All plugin identifiers currently registered in the real host cache
/// (direct cache iteration).
fn plugin_ids() -> Vec {
let count = plugin_count();
let mut out = Vec::new();
for i in 0..count {
if let Some(id) = host_plugin_id_at(i as usize) {
out.push(id);
}
}
out
}
// ---------------------------------------------------------------------------
// oakengine_plugin_set_active_viewer_provider
// ---------------------------------------------------------------------------
/// Legal matrix for the active-viewer provider: fn Some/None × userdata
/// ptr/NULL all register (or clear) with `OAKENGINE_OK`.
#[test]
fn active_viewer_provider_register_clear_matrix() {
common::force_link();
unsafe extern "C" fn viewer(_userdata: *mut c_void) -> *mut OakEngineNode {
std::ptr::null_mut()
}
let mut userdata = 42i32;
let ud = &mut userdata as *mut i32 as *mut c_void;
// Some(fn) + userdata.
assert_eq!(
oakengine_plugin_set_active_viewer_provider(Some(viewer), ud),
0
);
// Some(fn) + NULL userdata (userdata is opaque, NULL is legal).
assert_eq!(
oakengine_plugin_set_active_viewer_provider(Some(viewer), std::ptr::null_mut()),
0
);
// None clears (NULL fn), userdata is then ignored but still legal.
assert_eq!(oakengine_plugin_set_active_viewer_provider(None, ud), 0);
assert_eq!(
oakengine_plugin_set_active_viewer_provider(None, std::ptr::null_mut()),
0
);
// Register again and clear, so the process-global state ends neutral.
assert_eq!(
oakengine_plugin_set_active_viewer_provider(Some(viewer), std::ptr::null_mut()),
0
);
assert_eq!(
oakengine_plugin_set_active_viewer_provider(None, std::ptr::null_mut()),
0
);
}
// ---------------------------------------------------------------------------
// oakengine_plugin_set_progress_reporter_factory
// ---------------------------------------------------------------------------
/// Legal matrix for the progress-reporter factory: full factory, clear
/// (all NULL), partial registrations and NULL userdata all return
/// `OAKENGINE_OK`.
#[test]
fn progress_reporter_factory_register_clear_matrix() {
common::force_link();
unsafe extern "C" fn create(
_m: *const c_char,
_t: *const c_char,
_u: *mut c_void,
) -> *mut c_void {
std::ptr::null_mut()
}
unsafe extern "C" fn destroy(_r: *mut c_void, _u: *mut c_void) {}
unsafe extern "C" fn is_cancelled(_r: *mut c_void, _u: *mut c_void) -> c_int {
0
}
unsafe extern "C" fn set_progress(_r: *mut c_void, _p: f64, _u: *mut c_void) {}
let mut userdata = 7i64;
let ud = &mut userdata as *mut i64 as *mut c_void;
// Full factory + userdata.
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
Some(create),
Some(destroy),
Some(is_cancelled),
Some(set_progress),
ud,
),
0
);
// All-NULL clears (NULL userdata too).
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
None,
None,
None,
None,
std::ptr::null_mut()
),
0
);
// Partial registrations are accepted (the facade stores what it gets).
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
Some(create),
None,
None,
None,
std::ptr::null_mut()
),
0
);
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(None, Some(destroy), None, None, ud),
0
);
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
None,
None,
Some(is_cancelled),
None,
std::ptr::null_mut()
),
0
);
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(None, None, None, Some(set_progress), ud),
0
);
// Back to cleared.
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
None,
None,
None,
None,
std::ptr::null_mut()
),
0
);
}
// ---------------------------------------------------------------------------
// oakengine_plugin_load_plugins
// ---------------------------------------------------------------------------
/// NULL path → facade `E_INVALID`, never a crash.
#[test]
fn load_plugins_null_path() {
with_host(|| {
common::force_link();
let before = alive();
assert_eq!(
unsafe { oakengine_plugin_load_plugins(std::ptr::null()) },
E_INVALID
);
assert_eq!(
alive(),
before,
"failed scan must not touch the host registry"
);
});
}
/// Empty string path is a documented no-op (canonicalize fails, not a
/// directory → host returns OK; the C++ host never errors on a missing
/// path).
#[test]
fn load_plugins_empty_string_path() {
with_host(|| {
common::force_link();
let cs = CString::new("").unwrap();
let before = alive();
assert_eq!(unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) }, 0);
assert_eq!(alive(), before);
});
}
/// Nonexistent path: silently skipped with OK (olivehost.cpp add_plugin_path
/// semantics), no crash.
#[test]
fn load_plugins_nonexistent_path() {
with_host(|| {
common::force_link();
let dir =
std::env::temp_dir().join(format!("oak-it-plugin-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); // guarantee absence
let before = alive();
assert_eq!(scan_facade(&dir), 0);
assert_eq!(alive(), before);
});
}
/// A path that points at a regular file (not a directory) is a documented
/// no-op returning OK.
#[test]
fn load_plugins_path_is_a_file() {
with_host(|| {
common::force_link();
let dir = fresh_temp_dir("file-scan");
let file = dir.join("not-a-dir");
std::fs::write(&file, b"hi").unwrap();
let before = alive();
assert_eq!(scan_facade(&file), 0);
assert_eq!(alive(), before);
});
}
/// Empty directory scans cleanly (OK), changes nothing in the plugin
/// cache, and a repeat scan of the same path is deduplicated (also OK) —
/// the meaningful size=0 / index-range ground state.
#[test]
fn load_plugins_empty_dir_and_dedup() {
with_host(|| {
common::force_link();
let dir = fresh_temp_dir("empty");
let count_before = plugin_count();
let before = alive();
assert_eq!(scan_facade(&dir), 0);
assert_eq!(
plugin_count(),
count_before,
"empty dir must not register plugins"
);
// Same path again → dedup no-op, still OK.
assert_eq!(scan_facade(&dir), 0);
assert_eq!(plugin_count(), count_before);
assert_eq!(alive(), before);
});
}
/// Unicode path (non-ASCII directory name) scans cleanly.
#[test]
fn load_plugins_unicode_path() {
with_host(|| {
common::force_link();
let dir = fresh_temp_dir("unicode");
let unicode = dir.join("插件-目录-β");
std::fs::create_dir_all(&unicode).unwrap();
let before = alive();
assert_eq!(scan_facade(&unicode), 0);
assert_eq!(alive(), before);
});
}
/// Non-UTF-8 path bytes: the module rejects them with `OAKPLUGIN_E_INVALID`
/// which passes through the facade untranslated (-90001), never a crash.
#[test]
fn load_plugins_non_utf8_path() {
with_host(|| {
common::force_link();
let cs = CString::new(&b"/tmp/oak-it-plugin-\xff\xfe"[..]).unwrap();
let before = alive();
assert_eq!(
unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) },
PLUGIN_E_INVALID
);
assert_eq!(alive(), before);
});
}
/// End-to-end legal path: `oakengine_plugin_load_plugins` against a real
/// directory containing the real OFX test plugin bundle registers the
/// plugin in the real host cache (dlopen + setHost + load + describe all
/// run). Verified through the module's own introspection, plus a repeat
/// scan (dedup) and the alive counter.
///
/// Skips when the test plugin was not built (see module docs).
#[test]
fn load_plugins_real_bundle_end_to_end() {
with_host(|| {
common::force_link();
// A fresh directory so "scan registers the plugin" is deterministic;
// the env-injected mode falls back to the shared dir.
let Some(dir) = fresh_test_plugin_scan_dir("e2e") else {
println!("SKIP: test plugin bundle unavailable (oakplugin build.rs output missing)");
return;
};
let before = alive();
let ids_before = plugin_ids();
let had_test_plugin = ids_before.iter().any(|id| id == TEST_PLUGIN_ID);
let count_before = plugin_count();
// The facade scan returns OK and — unless the test plugin ids were
// already registered by an earlier scan in this binary (the host
// dedups globally by identifier, so a second scan of the same
// bundle binary is a no-op) — registers the test plugin.
assert_eq!(scan_facade(&dir), 0);
let ids_after = plugin_ids();
assert!(
ids_after.iter().any(|id| id == TEST_PLUGIN_ID),
"test plugin id must be discoverable after scan (ids: {ids_after:?})"
);
if !had_test_plugin {
assert!(
plugin_count() > count_before,
"a first scan of the real bundle must register the plugin ({} -> {})",
count_before,
plugin_count()
);
}
// Label lookup for a known id resolves (phase 1: the id itself).
let label = host_plugin_label(TEST_PLUGIN_ID);
assert!(label.is_some(), "scanned test plugin must expose a label");
// Repeat scan of the same path is deduplicated: still OK, cache
// unchanged, alive counter untouched.
let count_after_first = plugin_count();
assert_eq!(scan_facade(&dir), 0);
assert_eq!(plugin_count(), count_after_first);
assert_eq!(alive(), before, "scan must not leak host instances");
});
}
// ---------------------------------------------------------------------------
// oakengine_plugin_node_push_button_clicked
// ---------------------------------------------------------------------------
/// Documented stub: the oakplugin crate has no push-button API (the OFX
/// button-param trigger is C++-only), so every input combination returns
/// `OAKENGINE_E_FAILED` and never reads its arguments.
#[test]
fn push_button_clicked_documented_stub() {
common::force_link();
// NULL node + NULL button.
assert_eq!(
unsafe {
oakengine_plugin_node_push_button_clicked(std::ptr::null_mut(), std::ptr::null())
},
E_FAILED
);
// NULL node + button id.
assert_eq!(
unsafe { oakengine_plugin_node_push_button_clicked(std::ptr::null_mut(), c"btn".as_ptr()) },
E_FAILED
);
// Non-NULL node (empty handle) + NULL button.
let mut node = OakEngineNode {
handle: CHandle::null(),
};
assert_eq!(
unsafe { oakengine_plugin_node_push_button_clicked(&mut node, std::ptr::null()) },
E_FAILED
);
// Non-NULL node + button id.
assert_eq!(
unsafe { oakengine_plugin_node_push_button_clicked(&mut node, c"btn".as_ptr()) },
E_FAILED
);
}
// ---------------------------------------------------------------------------
// Backend destroy contract (the family's only free surface)
// ---------------------------------------------------------------------------
/// The facade plugin family exports no free/destroy function; its only
/// destroy surface is the backend `oakplugin_instance_free`. Contracts
/// verified against the real host: free(NULL)/free(empty)/double-free are
/// no-ops, unknown ids yield an empty handle (documented), and a real
/// instance create → free round trip restores the module's alive counter
/// to baseline (the leak assertion for this family).
#[test]
fn backend_instance_free_contracts() {
with_host(|| {
common::force_link();
let base = alive();
// free(NULL-equivalent) and free(empty handle) are no-ops.
host_instance_free(&mut None);
let mut empty: Option>> = None;
host_instance_free(&mut empty);
assert!(empty.is_none(), "free must leave the handle emptied");
// Unknown plugin id → empty handle, not a crash.
let mut h = host_instance_create("org.oak.not-a-plugin");
assert!(h.is_none());
host_instance_free(&mut h);
assert_eq!(alive(), base);
// Real plugin: create +1, free back to baseline, double-free safe.
let Some(dir) = test_plugin_scan_dir() else {
println!("SKIP: test plugin bundle unavailable (oakplugin build.rs output missing)");
return;
};
assert_eq!(scan_facade(&dir), 0);
let mut inst = host_instance_create(TEST_PLUGIN_ID);
assert!(
inst.is_some(),
"scanned test plugin must create an instance"
);
assert_eq!(alive(), base + 1, "one live instance must be registered");
host_instance_free(&mut inst);
assert!(inst.is_none());
assert_eq!(
alive(),
base,
"free must return the alive counter to baseline"
);
// Double free of the already-emptied handle is a no-op.
host_instance_free(&mut inst);
assert_eq!(alive(), base);
});
}