642fa716d1
Add digest-auth sideload installs, debugger log capture, and live Roku integration coverage so the full Roku milestone is validated on hardware.
229 lines
6.6 KiB
Rust
229 lines
6.6 KiB
Rust
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
|
|
}
|