use std::io::Write as _;
use std::path::Path;
use zip::write::SimpleFileOptions;
use super::activation::ActivationState;
use super::config::ExtensionConfig;
use super::manager::ExtensionManager;
use crate::operation::Operation;
const TEST_FIXTURE_WASM: &[u8] = include_bytes!(
"../../../../extensions/test-fixture/target/wasm32-unknown-unknown/release/oo_test_fixture.wasm"
);
fn wat(src: &str) -> Vec<u8> {
wat::parse_str(src).expect("WAT compilation failed")
}
fn minimal_wasm(detect_returns: i32, extra_exports: &str) -> Vec<u8> {
let src = format!(
r#"
(module
(import "oo" "__oo_host_call" (func (param i32 i32)))
(memory (export "memory") 1)
;; Bump allocator: always hand out offset 128 — safe for small test payloads.
(func (export "oo_alloc") (param i32) (result i32) i32.const 128)
(func (export "oo_free") (param i32 i32))
(func (export "oo_on_message") (param i32 i32))
(func (export "oo_init"))
(func (export "detect_project") (result i32) i32.const {detect_returns})
(func (export "oo_event") (param i32 i32))
{extra_exports}
)
"#,
detect_returns = detect_returns,
extra_exports = extra_exports,
);
wat(&src)
}
fn make_cyix(
dir: &Path,
name: &str,
version: &str,
wasm_bytes: &[u8],
events: &[&str],
commands: &[(&str, &str, &str)], ) -> std::path::PathBuf {
let events_yaml = if events.is_empty() {
"events: []".to_owned()
} else {
let list: String = events.iter().map(|e| format!(" - {}\n", e)).collect();
format!("events:\n{}", list)
};
let commands_yaml = if commands.is_empty() {
"commands: []".to_owned()
} else {
let list: String = commands
.iter()
.map(|(id, title, function)| {
format!(
" - id: {}\n title: {}\n function: {}\n",
id, title, function
)
})
.collect();
format!("commands:\n{}", list)
};
let manifest = format!(
"name: {name}\nversion: \"{version}\"\nide_api_version: \"0.1\"\nwasm: extension.wasm\n{events_yaml}\n{commands_yaml}\n",
);
let path = dir.join(format!("{}.cyix", name));
let file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let opts = SimpleFileOptions::default();
zip.start_file("extension.yaml", opts).unwrap();
zip.write_all(manifest.as_bytes()).unwrap();
zip.start_file("extension.wasm", opts).unwrap();
zip.write_all(wasm_bytes).unwrap();
zip.finish().unwrap();
path
}
fn op_tx() -> tokio::sync::mpsc::UnboundedSender<Vec<crate::operation::Operation>> {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
tx
}
#[test]
fn single_extension_is_loaded() {
let dir = tempfile::tempdir().unwrap();
let wasm = minimal_wasm(1, "");
make_cyix(dir.path(), "test-ext", "0.1.0", &wasm, &[], &[]);
let (mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
let states = mgr.states();
assert_eq!(states.len(), 1, "expected exactly one loaded extension");
assert_eq!(states[0].0, "test-ext");
assert_eq!(states[0].1, "0.1.0");
assert_eq!(states[0].2, ActivationState::Auto);
}
#[test]
fn no_cyix_files_loads_nothing() {
let dir = tempfile::tempdir().unwrap();
let (mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
assert!(mgr.states().is_empty());
}
#[test]
fn detect_project_disables_irrelevant_extension() {
let dir = tempfile::tempdir().unwrap();
let wasm = minimal_wasm(0, "");
make_cyix(dir.path(), "irrelevant-ext", "1.0.0", &wasm, &[], &[]);
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
assert_eq!(mgr.states()[0].2, ActivationState::Auto);
mgr.detect_project();
assert_eq!(
mgr.states()[0].2,
ActivationState::Disabled,
"extension that returned 0 from detect_project should be Disabled"
);
}
#[test]
fn detect_project_promotes_relevant_extension_to_project() {
let dir = tempfile::tempdir().unwrap();
let wasm = minimal_wasm(1, "");
make_cyix(dir.path(), "relevant-ext", "1.0.0", &wasm, &[], &[]);
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
mgr.detect_project();
assert_eq!(
mgr.states()[0].2,
ActivationState::Project,
"extension that returned 1 from detect_project should be promoted to Project"
);
}
#[test]
fn dispatch_event_reaches_active_extension() {
let dir = tempfile::tempdir().unwrap();
let wasm = minimal_wasm(1, "");
make_cyix(dir.path(), "evt-ext", "0.1.0", &wasm, &["file_saved"], &[]);
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
mgr.set_state("evt-ext", ActivationState::Global);
mgr.dispatch_event("file_saved", &std::collections::HashMap::new());
}
#[test]
fn dispatch_event_ignored_for_disabled_extension() {
let dir = tempfile::tempdir().unwrap();
let wasm = minimal_wasm(1, "");
make_cyix(
dir.path(),
"disabled-ext",
"0.1.0",
&wasm,
&["file_saved"],
&[],
);
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
mgr.dispatch_event("file_saved", &std::collections::HashMap::new());
}
#[test]
fn multiple_extensions_loaded_independently() {
let dir = tempfile::tempdir().unwrap();
let wasm_a = minimal_wasm(1, "");
let wasm_b = minimal_wasm(0, "");
make_cyix(dir.path(), "ext-a", "1.0.0", &wasm_a, &[], &[]);
make_cyix(dir.path(), "ext-b", "2.0.0", &wasm_b, &[], &[]);
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
mgr.detect_project();
let states = mgr.states();
assert_eq!(states.len(), 2);
let a = states.iter().find(|(n, _, _)| n == "ext-a").unwrap();
let b = states.iter().find(|(n, _, _)| n == "ext-b").unwrap();
assert_eq!(a.2, ActivationState::Project);
assert_eq!(b.2, ActivationState::Disabled);
}
#[test]
fn set_state_persists_and_is_reflected() {
let dir = tempfile::tempdir().unwrap();
let wasm = minimal_wasm(1, "");
make_cyix(dir.path(), "stateful-ext", "0.1.0", &wasm, &[], &[]);
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
assert_eq!(mgr.states()[0].2, ActivationState::Auto);
mgr.set_state("stateful-ext", ActivationState::Global);
assert_eq!(mgr.states()[0].2, ActivationState::Global);
mgr.set_state("stateful-ext", ActivationState::Disabled);
assert_eq!(mgr.states()[0].2, ActivationState::Disabled);
}
fn op_tx_rx() -> (
tokio::sync::mpsc::UnboundedSender<Vec<Operation>>,
tokio::sync::mpsc::UnboundedReceiver<Vec<Operation>>,
) {
tokio::sync::mpsc::unbounded_channel()
}
#[test]
fn fixture_loads_and_reports_correct_metadata() {
let dir = tempfile::tempdir().unwrap();
make_cyix(
dir.path(),
"test-fixture",
"0.1.0",
TEST_FIXTURE_WASM,
&["file_saved"],
&[("test.run", "Test Run", "cmd_run")],
);
let (mgr, _) = ExtensionManager::load_all(dir.path(), op_tx(), Vec::new(), &ExtensionConfig::allow_all());
let states = mgr.states();
assert_eq!(states.len(), 1);
assert_eq!(states[0].0, "test-fixture");
assert_eq!(states[0].1, "0.1.0");
assert_eq!(states[0].2, ActivationState::Auto);
}
#[test]
fn fixture_event_emits_show_notification() {
let dir = tempfile::tempdir().unwrap();
make_cyix(
dir.path(),
"test-fixture",
"0.1.0",
TEST_FIXTURE_WASM,
&["file_saved"],
&[],
);
let (tx, mut rx) = op_tx_rx();
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), tx, Vec::new(), &ExtensionConfig::allow_all());
mgr.set_state("test-fixture", ActivationState::Global);
mgr.dispatch_event("file_saved", &std::collections::HashMap::new());
let ops = rx
.try_recv()
.expect("expected an operation from the extension");
assert!(
ops.iter()
.any(|op| matches!(op, Operation::ShowNotification { .. })),
"expected ShowNotification, got: {:?}",
ops
);
}
#[test]
fn fixture_unsubscribed_event_produces_no_operations() {
let dir = tempfile::tempdir().unwrap();
make_cyix(
dir.path(),
"test-fixture",
"0.1.0",
TEST_FIXTURE_WASM,
&["file_saved"],
&[],
);
let (tx, mut rx) = op_tx_rx();
let (mut mgr, _) = ExtensionManager::load_all(dir.path(), tx, Vec::new(), &ExtensionConfig::allow_all());
mgr.set_state("test-fixture", ActivationState::Global);
mgr.dispatch_event("project_opened", &std::collections::HashMap::new());
assert!(
rx.try_recv().is_err(),
"extension should not receive an unsubscribed event"
);
}