#![expect(clippy::expect_used)] use tempfile::TempDir; use codex_core::CodexConversation; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use regex_lite::Regex; #[cfg(target_os = "linux")] use assert_cmd::cargo::cargo_bin; pub mod responses; pub mod test_codex; pub mod test_codex_exec; #[track_caller] pub fn assert_regex_match<'s>(pattern: &str, actual: &'s str) -> regex_lite::Captures<'s> { let regex = Regex::new(pattern).unwrap_or_else(|err| { panic!("failed to compile regex {pattern:?}: {err}"); }); regex .captures(actual) .unwrap_or_else(|| panic!("regex {pattern:?} did not match {actual:?}")) } /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. pub fn load_default_config_for_test(codex_home: &TempDir) -> Config { Config::load_from_base_config_with_overrides( ConfigToml::default(), default_test_overrides(), codex_home.path().to_path_buf(), ) .expect("defaults for test should always succeed") } #[cfg(target_os = "linux")] fn default_test_overrides() -> ConfigOverrides { ConfigOverrides { codex_linux_sandbox_exe: Some(cargo_bin("codex-linux-sandbox")), ..ConfigOverrides::default() } } #[cfg(not(target_os = "linux"))] fn default_test_overrides() -> ConfigOverrides { ConfigOverrides::default() } /// Builds an SSE stream body from a JSON fixture. /// /// The fixture must contain an array of objects where each object represents a /// single SSE event with at least a `type` field matching the `event:` value. /// Additional fields become the JSON payload for the `data:` line. An object /// with only a `type` field results in an event with no `data:` section. This /// makes it trivial to extend the fixtures as OpenAI adds new event kinds or /// fields. pub fn load_sse_fixture(path: impl AsRef) -> String { let events: Vec = serde_json::from_reader(std::fs::File::open(path).expect("read fixture")) .expect("parse JSON fixture"); events .into_iter() .map(|e| { let kind = e .get("type") .and_then(|v| v.as_str()) .expect("fixture event missing type"); if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { format!("event: {kind}\n\n") } else { format!("event: {kind}\ndata: {e}\n\n") } }) .collect() } pub fn load_sse_fixture_with_id_from_str(raw: &str, id: &str) -> String { let replaced = raw.replace("__ID__", id); let events: Vec = serde_json::from_str(&replaced).expect("parse JSON fixture"); events .into_iter() .map(|e| { let kind = e .get("type") .and_then(|v| v.as_str()) .expect("fixture event missing type"); if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { format!("event: {kind}\n\n") } else { format!("event: {kind}\ndata: {e}\n\n") } }) .collect() } /// Same as [`load_sse_fixture`], but replaces the placeholder `__ID__` in the /// fixture template with the supplied identifier before parsing. This lets a /// single JSON template be reused by multiple tests that each need a unique /// `response_id`. pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) -> String { let raw = std::fs::read_to_string(path).expect("read fixture template"); let replaced = raw.replace("__ID__", id); let events: Vec = serde_json::from_str(&replaced).expect("parse JSON fixture"); events .into_iter() .map(|e| { let kind = e .get("type") .and_then(|v| v.as_str()) .expect("fixture event missing type"); if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { format!("event: {kind}\n\n") } else { format!("event: {kind}\ndata: {e}\n\n") } }) .collect() } pub async fn wait_for_event( codex: &CodexConversation, predicate: F, ) -> codex_core::protocol::EventMsg where F: FnMut(&codex_core::protocol::EventMsg) -> bool, { use tokio::time::Duration; wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await } pub async fn wait_for_event_match(codex: &CodexConversation, matcher: F) -> T where F: Fn(&codex_core::protocol::EventMsg) -> Option, { let ev = wait_for_event(codex, |ev| matcher(ev).is_some()).await; matcher(&ev).unwrap() } pub async fn wait_for_event_with_timeout( codex: &CodexConversation, mut predicate: F, wait_time: tokio::time::Duration, ) -> codex_core::protocol::EventMsg where F: FnMut(&codex_core::protocol::EventMsg) -> bool, { use tokio::time::Duration; use tokio::time::timeout; loop { // Allow a bit more time to accommodate async startup work (e.g. config IO, tool discovery) let ev = timeout(wait_time.max(Duration::from_secs(5)), codex.next_event()) .await .expect("timeout waiting for event") .expect("stream ended unexpectedly"); if predicate(&ev.msg) { return ev.msg; } } } pub fn sandbox_env_var() -> &'static str { codex_core::spawn::CODEX_SANDBOX_ENV_VAR } pub fn sandbox_network_env_var() -> &'static str { codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR } pub mod fs_wait { use anyhow::Result; use anyhow::anyhow; use notify::RecursiveMode; use notify::Watcher; use std::path::Path; use std::path::PathBuf; use std::sync::mpsc; use std::sync::mpsc::RecvTimeoutError; use std::time::Duration; use std::time::Instant; use tokio::task; use walkdir::WalkDir; pub async fn wait_for_path_exists( path: impl Into, timeout: Duration, ) -> Result { let path = path.into(); task::spawn_blocking(move || wait_for_path_exists_blocking(path, timeout)).await? } pub async fn wait_for_matching_file( root: impl Into, timeout: Duration, predicate: impl FnMut(&Path) -> bool + Send + 'static, ) -> Result { let root = root.into(); task::spawn_blocking(move || { let mut predicate = predicate; blocking_find_matching_file(root, timeout, &mut predicate) }) .await? } fn wait_for_path_exists_blocking(path: PathBuf, timeout: Duration) -> Result { if path.exists() { return Ok(path); } let watch_root = nearest_existing_ancestor(&path); let (tx, rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(move |res| { let _ = tx.send(res); })?; watcher.watch(&watch_root, RecursiveMode::Recursive)?; let deadline = Instant::now() + timeout; loop { if path.exists() { return Ok(path.clone()); } let now = Instant::now(); if now >= deadline { break; } let remaining = deadline.saturating_duration_since(now); match rx.recv_timeout(remaining) { Ok(Ok(_event)) => { if path.exists() { return Ok(path.clone()); } } Ok(Err(err)) => return Err(err.into()), Err(RecvTimeoutError::Timeout) => break, Err(RecvTimeoutError::Disconnected) => break, } } if path.exists() { Ok(path) } else { Err(anyhow!("timed out waiting for {:?}", path)) } } fn blocking_find_matching_file( root: PathBuf, timeout: Duration, predicate: &mut impl FnMut(&Path) -> bool, ) -> Result { let root = wait_for_path_exists_blocking(root, timeout)?; if let Some(found) = scan_for_match(&root, predicate) { return Ok(found); } let (tx, rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(move |res| { let _ = tx.send(res); })?; watcher.watch(&root, RecursiveMode::Recursive)?; let deadline = Instant::now() + timeout; while Instant::now() < deadline { let remaining = deadline.saturating_duration_since(Instant::now()); match rx.recv_timeout(remaining) { Ok(Ok(_event)) => { if let Some(found) = scan_for_match(&root, predicate) { return Ok(found); } } Ok(Err(err)) => return Err(err.into()), Err(RecvTimeoutError::Timeout) => break, Err(RecvTimeoutError::Disconnected) => break, } } if let Some(found) = scan_for_match(&root, predicate) { Ok(found) } else { Err(anyhow!("timed out waiting for matching file in {:?}", root)) } } fn scan_for_match(root: &Path, predicate: &mut impl FnMut(&Path) -> bool) -> Option { for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) { let path = entry.path(); if !entry.file_type().is_file() { continue; } if predicate(path) { return Some(path.to_path_buf()); } } None } fn nearest_existing_ancestor(path: &Path) -> PathBuf { let mut current = path; loop { if current.exists() { return current.to_path_buf(); } match current.parent() { Some(parent) => current = parent, None => return PathBuf::from("."), } } } } #[macro_export] macro_rules! skip_if_sandbox { () => {{ if ::std::env::var($crate::sandbox_env_var()) == ::core::result::Result::Ok("seatbelt".to_string()) { eprintln!( "{} is set to 'seatbelt', skipping test.", $crate::sandbox_env_var() ); return; } }}; ($return_value:expr $(,)?) => {{ if ::std::env::var($crate::sandbox_env_var()) == ::core::result::Result::Ok("seatbelt".to_string()) { eprintln!( "{} is set to 'seatbelt', skipping test.", $crate::sandbox_env_var() ); return $return_value; } }}; } #[macro_export] macro_rules! skip_if_no_network { () => {{ if ::std::env::var($crate::sandbox_network_env_var()).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." ); return; } }}; ($return_value:expr $(,)?) => {{ if ::std::env::var($crate::sandbox_network_env_var()).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." ); return $return_value; } }}; }