use std::ffi::OsStr; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; fn which(cmd: &str) -> Option { which::which(cmd).ok() } fn chrome_path() -> Option { // Prefer the standard macOS Chrome app bundle. let mac = std::path::PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); if mac.exists() { return Some(mac); } which("google-chrome") .or_else(|| which("google-chrome-stable")) .or_else(|| which("chromium")) } fn ec_node_path() -> std::path::PathBuf { if let Ok(value) = std::env::var("EC_NODE_BIN") { return value.into(); } if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec_node") { return value.into(); } if let Ok(value) = std::env::var("CARGO_BIN_EXE_ec-node") { return value.into(); } let exe = std::env::current_exe().expect("current_exe"); let debug_dir = exe .parent() .and_then(|p| p.parent()) .expect("expected target/debug/deps"); debug_dir.join("ec-node") } fn generate_ts_fixture(out: &std::path::Path) -> anyhow::Result<()> { // Deterministic-ish fixture: single-threaded x264, fixed GOP, sine audio. let status = Command::new("ffmpeg") .arg("-hide_banner") .arg("-loglevel") .arg("error") .arg("-nostdin") .arg("-y") .arg("-f") .arg("lavfi") .arg("-i") .arg("testsrc2=size=1280x720:rate=30") .arg("-f") .arg("lavfi") .arg("-i") .arg("sine=frequency=1000:sample_rate=48000") .arg("-t") .arg("12") .arg("-map") .arg("0:v:0") .arg("-map") .arg("1:a:0") .arg("-c:v") .arg("libx264") .arg("-pix_fmt") .arg("yuv420p") .arg("-g") .arg("60") .arg("-keyint_min") .arg("60") .arg("-sc_threshold") .arg("0") .arg("-bf") .arg("0") .arg("-threads") .arg("1") .arg("-c:a") .arg("aac") .arg("-b:a") .arg("128k") .arg("-ac") .arg("2") .arg("-ar") .arg("48000") .arg("-f") .arg("mpegts") .arg(out) .status()?; if !status.success() { anyhow::bail!("ffmpeg fixture generation failed with {status}"); } Ok(()) } fn click_css(tab: &headless_chrome::Tab, css: &str) -> anyhow::Result<()> { tab.wait_for_element(css)?.click()?; Ok(()) } fn wait_for_text( tab: &headless_chrome::Tab, needle: &str, timeout: Duration, ) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { let js = format!( r#"(function() {{ return document.body && (document.body.innerText || '').includes({n}); }})();"#, n = serde_json::to_string(needle).unwrap() ); let v = tab.evaluate(&js, false)?; if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { return Ok(()); } std::thread::sleep(Duration::from_millis(200)); } anyhow::bail!("timed out waiting for text: {needle}"); } fn wait_for_blob_video(tab: &headless_chrome::Tab, timeout: Duration) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { let js = r#"(function() { let v = document.querySelector('video'); if (!v) return false; if (typeof v.src !== 'string') return false; return v.src.startsWith('blob:'); })();"#; let v = tab.evaluate(js, false)?; if v.value.and_then(|v| v.as_bool()).unwrap_or(false) { return Ok(()); } std::thread::sleep(Duration::from_millis(200)); } anyhow::bail!("timed out waiting for video blob src"); } fn click_global_watch(tab: &headless_chrome::Tab, stream_id: &str) -> anyhow::Result { let js = format!( r#"(function() {{ let target = {sid}; let btn = document.querySelector(`button[data-stream-id="${{target}}"]`) || document.querySelector(`button[data_stream_id="${{target}}"]`); if (!btn) return false; btn.click(); return true; }})();"#, sid = serde_json::to_string(stream_id).unwrap() ); let v = tab.evaluate(&js, false)?; Ok(v.value.and_then(|v| v.as_bool()).unwrap_or(false)) } #[test] #[ignore] fn e2e_remote_website_directory_connects_to_local_direct_publisher() -> anyhow::Result<()> { if which("ffmpeg").is_none() { return Ok(()); // skip } let chrome = match chrome_path() { Some(p) => p, None => return Ok(()), // skip }; let site_url = std::env::var("EVERY_CHANNEL_SITE_URL") .unwrap_or_else(|_| "https://every.channel/".to_string()); let directory_url = std::env::var("EVERY_CHANNEL_DIRECTORY_URL") .unwrap_or_else(|_| "https://every.channel".to_string()); let ec_node = ec_node_path(); let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); let stream_id = format!("every.channel/e2e/{ts}"); let title = format!("E2E {ts}"); let tmp = std::env::temp_dir().join(format!("ec-e2e-remote-website-directory-{ts}")); let _ = std::fs::create_dir_all(&tmp); let input_ts = tmp.join("input.ts"); let chunk_dir = tmp.join("chunks"); generate_ts_fixture(&input_ts)?; let mut pub_child = Command::new(&ec_node) .arg("direct-publish") .arg("--directory-url") .arg(&directory_url) .arg("--stream-id") .arg(&stream_id) .arg("--title") .arg(&title) .arg("--chunk-dir") .arg(&chunk_dir) .arg("--chunk-ms") .arg("2000") .arg("--max-segments") .arg("6") .arg("ts") .arg(&input_ts) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::inherit()) .spawn()?; let launch_options = headless_chrome::LaunchOptionsBuilder::default() .path(Some(chrome)) .headless(true) .args(vec![ OsStr::new("--autoplay-policy=no-user-gesture-required"), OsStr::new("--mute-audio"), ]) .build() .unwrap(); let browser = headless_chrome::Browser::new(launch_options)?; let tab = browser.new_tab()?; tab.navigate_to(&site_url)?; tab.wait_until_navigated()?; // Refresh public list and watch our stream_id. click_css(&tab, "button[data-testid='global-refresh']")?; let deadline = Instant::now() + Duration::from_secs(60); loop { if click_global_watch(&tab, &stream_id)? { break; } if Instant::now() > deadline { anyhow::bail!("timed out waiting for stream_id to appear in global list"); } std::thread::sleep(Duration::from_millis(250)); let _ = click_global_watch(&tab, &stream_id)?; } // Website should go Live and show a blob video source. wait_for_text(&tab, "Live", Duration::from_secs(60))?; wait_for_blob_video(&tab, Duration::from_secs(60))?; // Cleanup. let _ = pub_child.kill(); let _ = pub_child.wait(); let _ = std::fs::remove_dir_all(&tmp); Ok(()) }