//! Integration tests for ADR-128 P2 — Wasmtime runtime + example WASM plugin. //! //! ## Test strategy //! //! ### Primary path (compiled .wasm) //! //! Loads `homecore_plugin_example.wasm` from the known release output path //! under the plugin-example's own target directory. If the binary is not //! present (i.e., the example hasn't been built yet), the primary test is //! skipped with a warning and the WAT-based fallback runs instead. //! //! To run the primary path: //! //! ```sh //! # From v2/crates/homecore-plugin-example: //! /c/Users/ruv/.cargo/bin/cargo build --target wasm32-unknown-unknown --release //! # Then from v2/: //! cargo test -p homecore-plugins --features wasmtime //! ``` //! //! ### Fallback path (inline WAT) //! //! Always runs. Uses `wat::parse_str` to compile a hand-written WAT module //! that implements the same temperature-threshold logic as the Rust plugin. //! This proves the Wasmtime linker works and all 4 host imports are wired //! correctly even without a pre-built `.wasm` binary. #[cfg(feature = "wasmtime")] mod wasmtime_tests { use homecore::HomeCore; use homecore_plugins::wasmtime_runtime::WasmtimeRuntime; use homecore_plugins::StateChangedEventJson; // ── Path to compiled example binary ──────────────────────────────────── /// Path to the pre-compiled example WASM relative to the workspace root. /// /// The example crate has its own isolated Cargo workspace so its target /// directory lives under the crate itself, not the v2/ workspace target. const EXAMPLE_WASM_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../../crates/homecore-plugin-example/target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm" ); // ── WAT fallback (always runnable) ───────────────────────────────────── /// WAT module implementing the same temperature-threshold logic as /// `homecore-plugin-example`. Used when the compiled .wasm is unavailable. /// /// Behaviour: /// - `plugin_setup` → subscribes to `sensor.test_temp` via `hc_state_subscribe` /// - `plugin_handle_state_changed` → parses the `new_state` field from /// the event JSON and calls `hc_state_set` to write `binary_sensor.test_alert` /// /// This WAT version uses a simplified string scan rather than full JSON /// parsing, which is sufficient for the test payloads. const THRESHOLD_WAT: &str = r#" (module (import "env" "hc_state_get" (func $hc_state_get (param i32 i32 i32 i32) (result i32))) (import "env" "hc_state_set" (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32))) (import "env" "hc_state_subscribe" (func $hc_state_subscribe (param i32 i32) (result i32))) (import "env" "hc_log" (func $hc_log (param i32 i32 i32))) (memory (export "memory") 2) (global $bump (mut i32) (i32.const 4096)) ;; Static data at known offsets: ;; 0: "sensor.test_temp" (16 bytes) ;; 64: "binary_sensor.test_alert" (24 bytes) ;; 128: "on" (2 bytes) ;; 192: "off" (3 bytes) ;; 256: "{}" (2 bytes) (data (i32.const 0) "sensor.test_temp") (data (i32.const 64) "binary_sensor.test_alert") (data (i32.const 128) "on") (data (i32.const 192) "off") (data (i32.const 256) "{}") (func (export "alloc") (param $size i32) (result i32) (local $ptr i32) (local.set $ptr (global.get $bump)) (global.set $bump (i32.add (global.get $bump) (local.get $size))) (local.get $ptr) ) (func (export "dealloc") (param i32 i32)) ;; plugin_setup: subscribe to sensor.test_temp (func (export "plugin_setup") (param i32 i32) (result i32) (call $hc_state_subscribe (i32.const 0) (i32.const 16)) drop (i32.const 0) ) ;; plugin_handle_state_changed(ptr, len) → i32 ;; ;; The host passes a JSON string. We scan for "\"new_state\":\"" and read ;; one or two ASCII digit bytes to determine if temp > 25 or < 20. ;; The test values are "26" (above 25) and "19" (below 20), so we read ;; the first two digits after the marker and compare numerically. ;; ;; Scan strategy: find byte sequence for "new_state":" ;; Then read the decimal integer that follows until '"'. ;; ;; We implement a simple integer parser inline in WAT. (func (export "plugin_handle_state_changed") (param $ptr i32) (param $len i32) (result i32) (local $i i32) ;; scan index into the event buffer (local $end i32) ;; ptr + len (local $num i32) ;; parsed integer temperature (local $neg i32) ;; 1 if negative (local $ch i32) ;; current character (local $found i32) ;; 1 if marker found ;; We look for the 13-byte sequence: "new_state":" ;; Simplified: scan for byte 'n','e','w' consecutively to find the field. ;; Full marker: "new_state":" (len=13 including both quotes and colon) ;; Bytes: 22 6e 65 77 5f 73 74 61 74 65 22 3a 22 ;; " n e w _ s t a t e " : " (local.set $end (i32.add (local.get $ptr) (local.get $len))) (local.set $i (local.get $ptr)) (local.set $found (i32.const 0)) ;; Scan for '"new_state":"' (block $done (loop $scan ;; Bounds check (br_if $done (i32.ge_u (i32.add (local.get $i) (i32.const 13)) (local.get $end))) ;; Check 13-byte marker (if (i32.and (i32.and (i32.eq (i32.load8_u (local.get $i)) (i32.const 0x22)) ;; " (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 1))) (i32.const 0x6e)) ;; n ) (i32.and (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 11))) (i32.const 0x3a)) ;; : (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 12))) (i32.const 0x22)) ;; " ) ) (then ;; Advance past marker to the value start (local.set $i (i32.add (local.get $i) (i32.const 13))) (local.set $found (i32.const 1)) (br $done) ) ) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $scan) ) ) ;; If not found or null value, return 0 (no-op). (if (i32.eqz (local.get $found)) (then (return (i32.const 0)))) ;; Parse integer from current position. (local.set $num (i32.const 0)) (local.set $neg (i32.const 0)) ;; Check for minus sign. (if (i32.lt_u (local.get $i) (local.get $end)) (then (local.set $ch (i32.load8_u (local.get $i))) (if (i32.eq (local.get $ch) (i32.const 0x2d)) ;; '-' (then (local.set $neg (i32.const 1)) (local.set $i (i32.add (local.get $i) (i32.const 1))) ) ) ) ) ;; Parse digits. (block $numDone (loop $digits (br_if $numDone (i32.ge_u (local.get $i) (local.get $end))) (local.set $ch (i32.load8_u (local.get $i))) ;; Stop at non-digit or dot (we ignore decimals for integer comparison) (br_if $numDone (i32.lt_u (local.get $ch) (i32.const 0x30))) ;; < '0' (br_if $numDone (i32.gt_u (local.get $ch) (i32.const 0x39))) ;; > '9' (local.set $num (i32.add (i32.mul (local.get $num) (i32.const 10)) (i32.sub (local.get $ch) (i32.const 0x30)) ) ) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $digits) ) ) ;; Apply negative sign. (if (local.get $neg) (then (local.set $num (i32.sub (i32.const 0) (local.get $num)))) ) ;; Apply threshold: > 25 → set alert ON; < 20 → set alert OFF. (if (i32.gt_s (local.get $num) (i32.const 25)) (then (call $hc_state_set (i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert" (i32.const 128) (i32.const 2) ;; state: "on" (i32.const 256) (i32.const 2) ;; attrs: "{}" ) drop ) ) (if (i32.lt_s (local.get $num) (i32.const 20)) (then (call $hc_state_set (i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert" (i32.const 192) (i32.const 3) ;; state: "off" (i32.const 256) (i32.const 2) ;; attrs: "{}" ) drop ) ) (i32.const 0) ) ) "#; // ── Helpers ────────────────────────────────────────────────────────────── fn build_rt_and_hc() -> (WasmtimeRuntime, HomeCore) { ( WasmtimeRuntime::new().expect("WasmtimeRuntime::new"), HomeCore::new(), ) } fn state_changed_event(entity_id: &str, new_state: &str) -> StateChangedEventJson { StateChangedEventJson::state_changed( entity_id, Some(new_state), serde_json::json!({}), ) } fn assert_alert_state(hc: &HomeCore, expected: &str) { let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap(); let state = hc .states() .get(&eid) .unwrap_or_else(|| panic!("binary_sensor.test_alert not found in state machine")); assert_eq!( state.state, expected, "binary_sensor.test_alert should be '{expected}' but was '{}'", state.state ); } // ── Primary test: compiled .wasm binary ────────────────────────────────── #[test] fn wasm_plugin_temp_threshold_compiled_binary() { let wasm_path = std::path::Path::new(EXAMPLE_WASM_PATH); if !wasm_path.exists() { eprintln!( "[SKIP] {EXAMPLE_WASM_PATH} not found. \ Build the example first:\n \ cd v2/crates/homecore-plugin-example && \ cargo build --target wasm32-unknown-unknown --release" ); return; // skip — binary not built yet } let wasm_bytes = std::fs::read(wasm_path) .expect("failed to read homecore_plugin_example.wasm"); let (rt, hc) = build_rt_and_hc(); let plugin = rt .load_wasm(&wasm_bytes, hc.clone()) .expect("load_wasm should succeed"); // Call plugin_setup — should subscribe to sensor.test_temp. let setup_result = plugin .call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#) .expect("plugin_setup should not trap"); assert_eq!(setup_result, 0, "plugin_setup should return 0"); // Verify subscription was recorded. assert!( plugin.subscriptions().contains(&"sensor.test_temp".to_owned()), "plugin should have subscribed to sensor.test_temp" ); // ── Scenario 1: temp = 26.0 → alert ON ────────────────────────────── let event_hot = state_changed_event("sensor.test_temp", "26.0"); plugin .call_state_changed(&event_hot) .expect("state_changed should not trap"); assert_alert_state(&hc, "on"); // ── Scenario 2: temp = 19.0 → alert OFF ───────────────────────────── let event_cold = state_changed_event("sensor.test_temp", "19.0"); plugin .call_state_changed(&event_cold) .expect("state_changed should not trap"); assert_alert_state(&hc, "off"); } // ── Fallback test: inline WAT (always runs) ─────────────────────────────── #[test] fn wasm_plugin_temp_threshold_wat_fallback() { let wasm_bytes = wat::parse_str(THRESHOLD_WAT).expect("WAT should parse"); let (rt, hc) = build_rt_and_hc(); let plugin = rt .load_wasm(&wasm_bytes, hc.clone()) .expect("load_wasm should succeed for WAT"); // plugin_setup → subscribes let r = plugin.call_setup("{}").expect("setup"); assert_eq!(r, 0); // ── Scenario 1: temp = 26 → alert ON ─────────────────────────────── let hot_event = StateChangedEventJson::state_changed( "sensor.test_temp", Some("26"), serde_json::json!({}), ); plugin .call_state_changed(&hot_event) .expect("state_changed should not trap"); assert_alert_state(&hc, "on"); // ── Scenario 2: temp = 19 → alert OFF ────────────────────────────── let cold_event = StateChangedEventJson::state_changed( "sensor.test_temp", Some("19"), serde_json::json!({}), ); plugin .call_state_changed(&cold_event) .expect("state_changed should not trap"); assert_alert_state(&hc, "off"); } // ── Linker smoke test ──────────────────────────────────────────────────── #[test] fn wasmtime_linker_wires_all_four_host_imports() { // A minimal WAT that calls all 4 host imports once and returns 0. const SMOKE_WAT: &str = r#" (module (import "env" "hc_state_get" (func (param i32 i32 i32 i32) (result i32))) (import "env" "hc_state_set" (func (param i32 i32 i32 i32 i32 i32) (result i32))) (import "env" "hc_state_subscribe" (func (param i32 i32) (result i32))) (import "env" "hc_log" (func (param i32 i32 i32))) (memory (export "memory") 1) (global $bump (mut i32) (i32.const 512)) (func (export "alloc") (param i32) (result i32) (local $p i32) (local.set $p (global.get $bump)) (global.set $bump (i32.add (global.get $bump) (local.get 0))) (local.get $p)) (func (export "dealloc") (param i32 i32)) (func (export "plugin_setup") (param i32 i32) (result i32) (i32.const 0)) (func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0)) ) "#; let wasm_bytes = wat::parse_str(SMOKE_WAT).expect("WAT"); let rt = WasmtimeRuntime::new().expect("rt"); let hc = HomeCore::new(); let plugin = rt.load_wasm(&wasm_bytes, hc).expect("instantiate"); let r = plugin.call_setup("{}").expect("setup"); assert_eq!(r, 0); } }