feat: finish Roku adapter dev-mode support

Add digest-auth sideload installs, debugger log capture, and live Roku
integration coverage so the full Roku milestone is validated on hardware.
This commit is contained in:
44r0n7
2026-04-14 09:35:26 -04:00
parent ca9a6c509a
commit 642fa716d1
9 changed files with 530 additions and 17 deletions
+228
View File
@@ -0,0 +1,228 @@
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<tokio::sync::Mutex<()>> = OnceLock::new();
fn live_target_ip() -> Option<IpAddr> {
env::var("TVCTL_LIVE_ROKU_IP")
.ok()
.and_then(|value| value.parse::<IpAddr>().ok())
}
fn live_expected_name() -> Option<String> {
env::var("TVCTL_LIVE_ROKU_NAME").ok()
}
fn live_dev_password() -> Option<String> {
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<u8> {
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
}