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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user