use std::{env, fs, net::IpAddr, process::Command, sync::OnceLock, time::Duration}; use chrono::Utc; use tokio::time::sleep; use tvctl::adapters::{Device, DeviceState, PowerState, TvAdapter, roku::RokuAdapter}; use uuid::Uuid; static LIVE_ROKU_LOCK: OnceLock> = OnceLock::new(); fn live_target_ip() -> Option { env::var("TVCTL_LIVE_ROKU_IP") .ok() .and_then(|value| value.parse::().ok()) } fn live_expected_name() -> Option { env::var("TVCTL_LIVE_ROKU_NAME").ok() } fn live_dev_password() -> Option { env::var("TVCTL_ROKU_DEV_PASSWORD").ok() } fn live_device(ip: IpAddr) -> Device { Device { id: Uuid::new_v4(), name: live_expected_name().unwrap_or_else(|| "Live Roku".to_string()), original_name: live_expected_name().unwrap_or_else(|| "Live Roku".to_string()), platform: "roku".to_string(), address: ip, port: 8060, is_default: false, discovered_at: Utc::now(), last_seen: Utc::now(), } } async fn live_guard() -> tokio::sync::MutexGuard<'static, ()> { LIVE_ROKU_LOCK .get_or_init(|| tokio::sync::Mutex::new(())) .lock() .await } async fn wait_for_active_app( adapter: &RokuAdapter, device: &Device, expected_platform_id: &str, attempts: usize, ) -> DeviceState { let mut last_state = adapter.state(device).await.expect("state should succeed"); for _ in 0..attempts { if last_state .active_app .as_ref() .map(|app| app.platform_id.as_str()) == Some(expected_platform_id) { return last_state; } sleep(Duration::from_millis(750)).await; last_state = adapter.state(device).await.expect("state should succeed"); } last_state } #[tokio::test] async fn live_discover_finds_expected_roku() { let Some(ip) = live_target_ip() else { return; }; let _guard = live_guard().await; let adapter = RokuAdapter::new(); let devices = adapter.discover().await.expect("discovery should succeed"); let device = devices .iter() .find(|device| device.address == ip) .expect("live Roku should be discovered"); if let Some(expected_name) = live_expected_name() { assert_eq!(device.name, expected_name); } } #[tokio::test] async fn live_state_and_apps_are_queryable() { let Some(ip) = live_target_ip() else { return; }; let _guard = live_guard().await; let adapter = RokuAdapter::new(); let device = live_device(ip); let state = adapter.state(&device).await.expect("state should succeed"); assert_eq!(state.device_id, device.id); assert_eq!(state.power, PowerState::On); let active_app = state.active_app.expect("active app should exist"); assert!(!active_app.name.is_empty()); let apps = adapter .list_apps(&device) .await .expect("app listing should succeed"); assert!(!apps.is_empty()); assert!(apps.iter().any(|app| app.name == "Netflix")); assert!(apps.iter().any(|app| app.name == "YouTube")); } #[tokio::test] async fn live_key_sequence_and_launch_work() { let Some(ip) = live_target_ip() else { return; }; let _guard = live_guard().await; let adapter = RokuAdapter::new(); let device = live_device(ip); adapter .key(&device, tvctl::adapters::TvKey::Home) .await .expect("home key should succeed"); let home_state = wait_for_active_app(&adapter, &device, "562859", 12).await; let home_app = home_state.active_app.expect("home app should exist"); assert_eq!(home_app.name, "Home"); adapter .launch(&device, "837") .await .expect("YouTube launch should succeed"); let launched_state = wait_for_active_app(&adapter, &device, "837", 12).await; let launched_app = launched_state .active_app .expect("launched app should exist"); assert_eq!(launched_app.platform_id, "837"); adapter .stop_app(&device) .await .expect("stop_app should return to home"); let restored_state = wait_for_active_app(&adapter, &device, "562859", 12).await; let restored_app = restored_state .active_app .expect("restored app should exist"); assert_eq!(restored_app.name, "Home"); } #[tokio::test] async fn live_dev_install_reload_and_logs_work() { let Some(ip) = live_target_ip() else { return; }; let Some(_password) = live_dev_password() else { return; }; let _guard = live_guard().await; let adapter = RokuAdapter::new(); let device = live_device(ip); let package = build_dev_test_zip(); adapter .dev_install(&device, &package) .await .expect("dev install should succeed"); adapter .dev_reload(&device) .await .expect("dev reload should succeed"); let state = wait_for_active_app(&adapter, &device, "dev", 12).await; let app = state.active_app.expect("dev app should be active"); assert_eq!(app.platform_id, "dev"); let logs = adapter .dev_logs(&device) .await .expect("dev logs should be readable"); let joined = logs.join("\n"); assert!(joined.contains("tvctl dev test starting")); assert!(joined.contains("tvctl heartbeat")); adapter .stop_app(&device) .await .expect("stop_app should return to home after dev test"); } fn build_dev_test_zip() -> Vec { let root = env::temp_dir().join(format!("tvctl-roku-live-{}", Uuid::new_v4())); let source_dir = root.join("source"); fs::create_dir_all(&source_dir).expect("source dir should exist"); fs::write( root.join("manifest"), "title=tvctl dev test\nsubtitle=tvctl dev test\nmajor_version=1\nminor_version=0\nbuild_version=3\n", ) .expect("manifest should write"); fs::write( source_dir.join("main.brs"), "sub Main()\n print \"tvctl dev test starting\"\n while true\n print \"tvctl heartbeat\"\n sleep(1000)\n end while\nend sub\n", ) .expect("main source should write"); let zip_path = root.join("tvctl-dev-test.zip"); let status = Command::new("zip") .current_dir(&root) .args([ "-qr", zip_path.to_str().expect("zip path should be utf-8"), "manifest", "source", ]) .status() .expect("zip command should run"); assert!(status.success(), "zip command should succeed"); let bytes = fs::read(&zip_path).expect("zip output should be readable"); let _ = fs::remove_dir_all(&root); bytes }