oo-ide 0.0.3

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Integration tests for the extension loading pipeline.
//!
//! Each test:
//!   1. Compiles a minimal WAT module to WASM bytes.
//!   2. Packages it into a `.cyix` ZIP archive in a temp directory.
//!   3. Exercises `ExtensionManager` and asserts the expected behaviour.

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;

/// Pre-built WASM for the test-fixture extension.
/// Rebuild with: `cargo build --target wasm32-unknown-unknown --release`
/// from `extensions/test-fixture/`, then copy the output here.
const TEST_FIXTURE_WASM: &[u8] = include_bytes!(
    "../../../../extensions/test-fixture/target/wasm32-unknown-unknown/release/oo_test_fixture.wasm"
);

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Compile a WAT snippet and return the binary WASM bytes.
fn wat(src: &str) -> Vec<u8> {
    wat::parse_str(src).expect("WAT compilation failed")
}

/// Minimal WAT module skeleton.
///
/// `detect_returns` controls the return value of `detect_project`.
/// `extra_exports` is appended verbatim inside the module body, e.g. to add
/// additional exported functions for command/event handlers.
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)
}

/// Write `extension.yaml` + `extension.wasm` into a `.cyix` ZIP archive
/// inside `dir`.  Returns the path to the archive.
fn make_cyix(
    dir: &Path,
    name: &str,
    version: &str,
    wasm_bytes: &[u8],
    events: &[&str],
    commands: &[(&str, &str, &str)], // (id, title, function)
) -> 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
}

/// Create an `op_tx` channel, discarding all received operations.
fn op_tx() -> tokio::sync::mpsc::UnboundedSender<Vec<crate::operation::Operation>> {
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    tx
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[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();
    // detect_project returns 0 — extension is not relevant for this project.
    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();
    // detect_project returns 1 — extension is relevant; state should be
    // promoted to Project so it is active.
    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);

    // Should not panic even though the stub handler is a no-op.
    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());
    // Extension stays Unknown (not active) — dispatch should be a no-op.
    mgr.dispatch_event("file_saved", &std::collections::HashMap::new());
    // No panic and no operations emitted — test passes by not crashing.
}

#[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);
}

// ---------------------------------------------------------------------------
// Round-trip tests using the pre-built test-fixture extension
// ---------------------------------------------------------------------------

/// Helper: create an op channel where the receiver is returned so the test
/// can inspect what operations the extension emitted.
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());

    // The fixture's oo_event handler calls show_notification, which goes
    // through __oo_host_call → handle_host_request → op_tx.
    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();
    // Fixture subscribes to "file_saved" but we dispatch "project_opened".
    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"
    );
}