diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index eb2b76de07..e09e1b53f5 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -1,17 +1,7 @@ mod experimental_api; -#[cfg(test)] -mod export; mod precomputed_exports; -#[cfg(test)] -#[path = "precomputed_exports_tests.rs"] -mod precomputed_exports_tests; mod protocol; pub mod rpc; -#[cfg(test)] -mod schema_fixtures; -#[cfg(test)] -#[path = "schema_fixtures_tests.rs"] -mod schema_fixtures_tests; pub use experimental_api::*; pub use precomputed_exports::GenerateTsOptions; @@ -69,3 +59,14 @@ pub(crate) use codex_app_server_protocol_noop_macros::TS; pub(crate) use schemars::JsonSchema; #[cfg(test)] pub(crate) use ts_rs::TS; + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; +// Release tooling selects this module's schema writer by its exact test name. +#[cfg(test)] +mod schema_fixtures_tests; +#[cfg(test)] +use tests::export; +#[cfg(test)] +use tests::schema_fixtures; diff --git a/codex-rs/app-server-protocol/src/lib_tests.rs b/codex-rs/app-server-protocol/src/lib_tests.rs new file mode 100644 index 0000000000..06f93d7212 --- /dev/null +++ b/codex-rs/app-server-protocol/src/lib_tests.rs @@ -0,0 +1,8 @@ +#![cfg(test)] + +#[path = "export.rs"] +pub(crate) mod export; +#[path = "precomputed_exports_tests.rs"] +mod precomputed_exports_tests; +#[path = "schema_fixtures.rs"] +pub(crate) mod schema_fixtures; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ed787e10f9..ae5941119f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1574,87 +1574,5 @@ fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransp } #[cfg(test)] -#[path = "stderr_logging_tests.rs"] -mod stderr_logging_tests; - -#[cfg(test)] -mod tests { - use super::LogFormat; - use super::ShutdownAction; - use super::ShutdownSignal; - use super::ShutdownState; - #[cfg(debug_assertions)] - use super::loader_overrides_with_test_user_config_file; - use super::turn_admission::TurnAdmission; - #[cfg(debug_assertions)] - use codex_config::LoaderOverrides; - #[cfg(debug_assertions)] - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - - #[test] - fn shutdown_waits_for_admitted_requests_but_force_remains_available() { - let admission = TurnAdmission::default(); - let active = admission.subscribe_active(); - let in_flight = admission.admit().expect("request admitted"); - let mut shutdown = ShutdownState::default(); - let connection_count = 1; - let running_turn_count = 0; - shutdown.on_signal( - ShutdownSignal::Forceable, - connection_count, - running_turn_count, - &admission, - ); - assert!(matches!( - shutdown.update(running_turn_count, *active.borrow(), connection_count), - ShutdownAction::Noop - )); - shutdown.on_signal( - ShutdownSignal::Forceable, - connection_count, - running_turn_count, - &admission, - ); - assert!(shutdown.forced()); - assert!(matches!( - shutdown.update(running_turn_count, *active.borrow(), connection_count), - ShutdownAction::Finish - )); - drop(in_flight); - } - - #[test] - fn log_format_from_env_value_matches_json_values_case_insensitively() { - assert_eq!(LogFormat::from_env_value(Some("json")), LogFormat::Json); - assert_eq!(LogFormat::from_env_value(Some("JSON")), LogFormat::Json); - assert_eq!(LogFormat::from_env_value(Some(" Json ")), LogFormat::Json); - } - - #[test] - fn log_format_from_env_value_defaults_for_non_json_values() { - assert_eq!( - LogFormat::from_env_value(/*value*/ None), - LogFormat::Default - ); - assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default); - assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); - assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); - } - - #[cfg(debug_assertions)] - #[test] - fn debug_test_user_config_file_overrides_loader_path() { - let path = std::env::temp_dir().join("codex-app-server-test-config.toml"); - let loader_overrides = loader_overrides_with_test_user_config_file( - LoaderOverrides::default(), - Some(path.clone()), - ) - .expect("test config path should be valid"); - - assert_eq!( - loader_overrides.user_config_path, - Some(AbsolutePathBuf::from_absolute_path(path).expect("absolute test path")) - ); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/lib_tests.rs b/codex-rs/app-server/src/lib_tests.rs new file mode 100644 index 0000000000..d24a1d6df4 --- /dev/null +++ b/codex-rs/app-server/src/lib_tests.rs @@ -0,0 +1,83 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +use super::LogFormat; +use super::ShutdownAction; +use super::ShutdownSignal; +use super::ShutdownState; +#[cfg(debug_assertions)] +use super::loader_overrides_with_test_user_config_file; +use super::turn_admission::TurnAdmission; +#[cfg(debug_assertions)] +use codex_config::LoaderOverrides; +#[cfg(debug_assertions)] +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[test] +fn shutdown_waits_for_admitted_requests_but_force_remains_available() { + let admission = TurnAdmission::default(); + let active = admission.subscribe_active(); + let in_flight = admission.admit().expect("request admitted"); + let mut shutdown = ShutdownState::default(); + let connection_count = 1; + let running_turn_count = 0; + shutdown.on_signal( + ShutdownSignal::Forceable, + connection_count, + running_turn_count, + &admission, + ); + assert!(matches!( + shutdown.update(running_turn_count, *active.borrow(), connection_count), + ShutdownAction::Noop + )); + shutdown.on_signal( + ShutdownSignal::Forceable, + connection_count, + running_turn_count, + &admission, + ); + assert!(shutdown.forced()); + assert!(matches!( + shutdown.update(running_turn_count, *active.borrow(), connection_count), + ShutdownAction::Finish + )); + drop(in_flight); +} + +#[test] +fn log_format_from_env_value_matches_json_values_case_insensitively() { + assert_eq!(LogFormat::from_env_value(Some("json")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some("JSON")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some(" Json ")), LogFormat::Json); +} + +#[test] +fn log_format_from_env_value_defaults_for_non_json_values() { + assert_eq!( + LogFormat::from_env_value(/*value*/ None), + LogFormat::Default + ); + assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); +} + +#[cfg(debug_assertions)] +#[test] +fn debug_test_user_config_file_overrides_loader_path() { + let path = std::env::temp_dir().join("codex-app-server-test-config.toml"); + let loader_overrides = + loader_overrides_with_test_user_config_file(LoaderOverrides::default(), Some(path.clone())) + .expect("test config path should be valid"); + + assert_eq!( + loader_overrides.user_config_path, + Some(AbsolutePathBuf::from_absolute_path(path).expect("absolute test path")) + ); +} + +#[path = "stderr_logging_tests.rs"] +mod stderr_logging_tests; diff --git a/codex-rs/app-server/src/stderr_logging_tests.rs b/codex-rs/app-server/src/stderr_logging_tests.rs index eae2e7504a..3258832505 100644 --- a/codex-rs/app-server/src/stderr_logging_tests.rs +++ b/codex-rs/app-server/src/stderr_logging_tests.rs @@ -36,7 +36,7 @@ async fn sqlite_transaction_completes_with_undrained_stderr(format: LogFormat) - let fmt = tracing_subscriber::fmt::layer() .with_writer(Mutex::new(writer)) .with_span_events(stderr_span_events()); - let filter = EnvFilter::new("off,codex_app_server::stderr_logging_tests=info"); + let filter = EnvFilter::new("off,codex_app_server::tests::stderr_logging_tests=info"); let fmt: StderrLogLayer = match format { LogFormat::Json => fmt.json().with_filter(filter).boxed(), LogFormat::Default => fmt.with_filter(filter).boxed(), diff --git a/codex-rs/aws-auth/src/lib.rs b/codex-rs/aws-auth/src/lib.rs index 8f5e2d3eab..498ad7ddec 100644 --- a/codex-rs/aws-auth/src/lib.rs +++ b/codex-rs/aws-auth/src/lib.rs @@ -315,223 +315,5 @@ impl AwsAuthError { } #[cfg(test)] -mod tests { - use std::time::Duration; - use std::time::UNIX_EPOCH; - - use aws_credential_types::Credentials; - use aws_credential_types::provider::error::CredentialsError; - use pretty_assertions::assert_eq; - - use super::*; - - fn test_context(session_token: Option<&str>) -> AwsAuthContext { - AwsAuthContext { - network_policy: Default::default(), - credentials_provider: SharedCredentialsProvider::new(Credentials::new( - "AKIDEXAMPLE", - "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - session_token.map(str::to_string), - /*expires_after*/ None, - "unit-test", - )), - region: "us-east-1".to_string(), - service: "bedrock".to_string(), - } - } - - pub(super) fn test_request() -> AwsRequestToSign { - let mut headers = HeaderMap::new(); - headers.insert( - http::header::CONTENT_TYPE, - http::HeaderValue::from_static("application/json"), - ); - headers.insert("x-test-header", http::HeaderValue::from_static("present")); - AwsRequestToSign { - method: Method::POST, - url: "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses".to_string(), - headers, - body: Bytes::from_static(br#"{"model":"openai.gpt-oss-120b-1:0"}"#), - } - } - - #[tokio::test] - async fn sign_adds_sigv4_headers_and_preserves_existing_headers() { - let signed = test_context(/*session_token*/ None) - .sign_at( - test_request(), - UNIX_EPOCH + Duration::from_secs(1_700_000_000), - ) - .await - .expect("request should sign"); - - assert_eq!( - signing::header_value(&signed.headers, http::header::CONTENT_TYPE.as_str()), - Some("application/json".to_string()) - ); - assert_eq!( - signing::header_value(&signed.headers, "x-test-header"), - Some("present".to_string()) - ); - assert_eq!( - signed.url, - "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses" - ); - assert!( - signing::header_value(&signed.headers, http::header::AUTHORIZATION.as_str()) - .is_some_and(|value| value.starts_with("AWS4-HMAC-SHA256 ")) - ); - assert!(signing::header_value(&signed.headers, "x-amz-date").is_some()); - } - - #[tokio::test] - async fn credentials_provider_adapter_converts_keys_and_provider_failures() { - #[derive(Debug)] - struct TestCredentialsProvider(Result); - - impl AwsCredentialsProvider for TestCredentialsProvider { - async fn credentials(&self) -> std::io::Result { - self.0 - .clone() - .map_err(|kind| std::io::Error::new(kind, "credential export failed")) - } - } - - let credentials = AwsCredentialsProviderAdapter( - Arc::new(TestCredentialsProvider(Ok(AwsAccessKeys { - access_key_id: "access-key-id".to_string(), - secret_access_key: "secret-access-key".to_string(), - session_token: Some("session-token".to_string()), - }))), - Default::default(), - ) - .provide_credentials() - .await - .expect("exported credentials should be available"); - - assert_eq!( - credentials, - Credentials::new( - "access-key-id", - "secret-access-key", - Some("session-token".to_string()), - /*expires_after*/ None, - "codex-bedrock-credential-export", - ) - ); - - for (kind, retryable) in [ - (std::io::ErrorKind::Other, true), - (std::io::ErrorKind::TimedOut, true), - (std::io::ErrorKind::InvalidData, false), - (std::io::ErrorKind::InvalidInput, false), - (std::io::ErrorKind::NotFound, false), - (std::io::ErrorKind::PermissionDenied, false), - ] { - let error = AwsCredentialsProviderAdapter( - Arc::new(TestCredentialsProvider(Err(kind))), - Default::default(), - ) - .provide_credentials() - .await - .expect_err("credential export failure should be propagated"); - let error = AwsAuthError::Credentials(error); - assert_eq!( - ( - error.is_retryable(), - error - .credentials_provider_error() - .map(|error| (error.kind(), error.to_string())), - ), - ( - retryable, - Some((kind, "credential export failed".to_string())) - ), - ); - } - } - - #[test] - fn credentials_provider_failures_are_retryable() { - assert!( - AwsAuthError::Credentials(CredentialsError::provider_error("temporarily unavailable")) - .is_retryable() - ); - assert!( - AwsAuthError::Credentials(CredentialsError::provider_timed_out(Duration::from_secs(1))) - .is_retryable() - ); - } - - #[test] - fn deterministic_aws_auth_errors_are_not_retryable() { - assert!(!AwsAuthError::EmptyService.is_retryable()); - assert!(!AwsAuthError::MissingProfile.is_retryable()); - assert!( - !AwsAuthError::Credentials(CredentialsError::not_loaded_no_source()).is_retryable() - ); - assert!( - !AwsAuthError::Credentials(CredentialsError::invalid_configuration("bad profile")) - .is_retryable() - ); - assert!( - !AwsAuthError::Credentials(CredentialsError::unhandled("unexpected response")) - .is_retryable() - ); - } - - #[tokio::test] - async fn sign_includes_session_token_when_credentials_have_one() { - let signed = test_context(Some("session-token")) - .sign_at( - test_request(), - UNIX_EPOCH + Duration::from_secs(1_700_000_000), - ) - .await - .expect("request should sign"); - - assert_eq!( - signing::header_value(&signed.headers, "x-amz-security-token"), - Some("session-token".to_string()) - ); - } - - #[tokio::test] - async fn load_rejects_invalid_configuration() { - let config = AwsAuthConfig { - profile: None, - region: None, - service: " ".to_string(), - }; - let err = AwsAuthContext::load( - config, - codex_http_client::HttpClientFactory::new( - codex_http_client::OutboundProxyPolicy::ReqwestDefault, - ), - ) - .await - .expect_err("empty service should be rejected"); - - assert_eq!(err.to_string(), "AWS service name must not be empty"); - - let config = AwsAuthConfig { - profile: None, - region: Some("us-east-1".to_string()), - service: "bedrock".to_string(), - }; - let err = AwsAuthContext::load_profile( - config, - codex_http_client::HttpClientFactory::new( - codex_http_client::OutboundProxyPolicy::ReqwestDefault, - ), - ) - .await - .expect_err("profile auth should require a configured profile"); - - assert_eq!(err.to_string(), "AWS profile must be configured"); - } -} - -#[cfg(test)] -#[path = "network_policy_tests.rs"] -mod network_policy_tests; +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/aws-auth/src/lib_tests.rs b/codex-rs/aws-auth/src/lib_tests.rs new file mode 100644 index 0000000000..a17ba2b879 --- /dev/null +++ b/codex-rs/aws-auth/src/lib_tests.rs @@ -0,0 +1,219 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +use std::time::Duration; +use std::time::UNIX_EPOCH; + +use aws_credential_types::Credentials; +use aws_credential_types::provider::error::CredentialsError; +use pretty_assertions::assert_eq; + +use super::*; + +fn test_context(session_token: Option<&str>) -> AwsAuthContext { + AwsAuthContext { + network_policy: Default::default(), + credentials_provider: SharedCredentialsProvider::new(Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + session_token.map(str::to_string), + /*expires_after*/ None, + "unit-test", + )), + region: "us-east-1".to_string(), + service: "bedrock".to_string(), + } +} + +pub(super) fn test_request() -> AwsRequestToSign { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + headers.insert("x-test-header", http::HeaderValue::from_static("present")); + AwsRequestToSign { + method: Method::POST, + url: "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses".to_string(), + headers, + body: Bytes::from_static(br#"{"model":"openai.gpt-oss-120b-1:0"}"#), + } +} + +#[tokio::test] +async fn sign_adds_sigv4_headers_and_preserves_existing_headers() { + let signed = test_context(/*session_token*/ None) + .sign_at( + test_request(), + UNIX_EPOCH + Duration::from_secs(1_700_000_000), + ) + .await + .expect("request should sign"); + + assert_eq!( + signing::header_value(&signed.headers, http::header::CONTENT_TYPE.as_str()), + Some("application/json".to_string()) + ); + assert_eq!( + signing::header_value(&signed.headers, "x-test-header"), + Some("present".to_string()) + ); + assert_eq!( + signed.url, + "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses" + ); + assert!( + signing::header_value(&signed.headers, http::header::AUTHORIZATION.as_str()) + .is_some_and(|value| value.starts_with("AWS4-HMAC-SHA256 ")) + ); + assert!(signing::header_value(&signed.headers, "x-amz-date").is_some()); +} + +#[tokio::test] +async fn credentials_provider_adapter_converts_keys_and_provider_failures() { + #[derive(Debug)] + struct TestCredentialsProvider(Result); + + impl AwsCredentialsProvider for TestCredentialsProvider { + async fn credentials(&self) -> std::io::Result { + self.0 + .clone() + .map_err(|kind| std::io::Error::new(kind, "credential export failed")) + } + } + + let credentials = AwsCredentialsProviderAdapter( + Arc::new(TestCredentialsProvider(Ok(AwsAccessKeys { + access_key_id: "access-key-id".to_string(), + secret_access_key: "secret-access-key".to_string(), + session_token: Some("session-token".to_string()), + }))), + Default::default(), + ) + .provide_credentials() + .await + .expect("exported credentials should be available"); + + assert_eq!( + credentials, + Credentials::new( + "access-key-id", + "secret-access-key", + Some("session-token".to_string()), + /*expires_after*/ None, + "codex-bedrock-credential-export", + ) + ); + + for (kind, retryable) in [ + (std::io::ErrorKind::Other, true), + (std::io::ErrorKind::TimedOut, true), + (std::io::ErrorKind::InvalidData, false), + (std::io::ErrorKind::InvalidInput, false), + (std::io::ErrorKind::NotFound, false), + (std::io::ErrorKind::PermissionDenied, false), + ] { + let error = AwsCredentialsProviderAdapter( + Arc::new(TestCredentialsProvider(Err(kind))), + Default::default(), + ) + .provide_credentials() + .await + .expect_err("credential export failure should be propagated"); + let error = AwsAuthError::Credentials(error); + assert_eq!( + ( + error.is_retryable(), + error + .credentials_provider_error() + .map(|error| (error.kind(), error.to_string())), + ), + ( + retryable, + Some((kind, "credential export failed".to_string())) + ), + ); + } +} + +#[test] +fn credentials_provider_failures_are_retryable() { + assert!( + AwsAuthError::Credentials(CredentialsError::provider_error("temporarily unavailable")) + .is_retryable() + ); + assert!( + AwsAuthError::Credentials(CredentialsError::provider_timed_out(Duration::from_secs(1))) + .is_retryable() + ); +} + +#[test] +fn deterministic_aws_auth_errors_are_not_retryable() { + assert!(!AwsAuthError::EmptyService.is_retryable()); + assert!(!AwsAuthError::MissingProfile.is_retryable()); + assert!(!AwsAuthError::Credentials(CredentialsError::not_loaded_no_source()).is_retryable()); + assert!( + !AwsAuthError::Credentials(CredentialsError::invalid_configuration("bad profile")) + .is_retryable() + ); + assert!( + !AwsAuthError::Credentials(CredentialsError::unhandled("unexpected response")) + .is_retryable() + ); +} + +#[tokio::test] +async fn sign_includes_session_token_when_credentials_have_one() { + let signed = test_context(Some("session-token")) + .sign_at( + test_request(), + UNIX_EPOCH + Duration::from_secs(1_700_000_000), + ) + .await + .expect("request should sign"); + + assert_eq!( + signing::header_value(&signed.headers, "x-amz-security-token"), + Some("session-token".to_string()) + ); +} + +#[tokio::test] +async fn load_rejects_invalid_configuration() { + let config = AwsAuthConfig { + profile: None, + region: None, + service: " ".to_string(), + }; + let err = AwsAuthContext::load( + config, + codex_http_client::HttpClientFactory::new( + codex_http_client::OutboundProxyPolicy::ReqwestDefault, + ), + ) + .await + .expect_err("empty service should be rejected"); + + assert_eq!(err.to_string(), "AWS service name must not be empty"); + + let config = AwsAuthConfig { + profile: None, + region: Some("us-east-1".to_string()), + service: "bedrock".to_string(), + }; + let err = AwsAuthContext::load_profile( + config, + codex_http_client::HttpClientFactory::new( + codex_http_client::OutboundProxyPolicy::ReqwestDefault, + ), + ) + .await + .expect_err("profile auth should require a configured profile"); + + assert_eq!(err.to_string(), "AWS profile must be configured"); +} + +#[path = "network_policy_tests.rs"] +mod network_policy_tests; diff --git a/codex-rs/aws-auth/src/network_policy_tests.rs b/codex-rs/aws-auth/src/network_policy_tests.rs index adf4978fd7..b6eaa786b6 100644 --- a/codex-rs/aws-auth/src/network_policy_tests.rs +++ b/codex-rs/aws-auth/src/network_policy_tests.rs @@ -1,7 +1,7 @@ //! Exercises policy on AWS SDK HTTP and the SDK's credential selection. -use super::AwsAuthError::Policy; -use super::*; +use crate::AwsAuthError::Policy; +use crate::*; use aws_config::provider_config::ProviderConfig; use codex_http_client::DestinationPolicy; use codex_http_client::NetworkPolicyDenied::Revoked; @@ -36,7 +36,7 @@ async fn real_imds_credentials_stop_after_policy_revocation() -> Result<(), Box< region: "us-east-1".into(), service: "bedrock".into(), }; - let request = super::tests::test_request(); + let request = crate::tests::test_request(); let server = async { let mut credentials_sent = false; loop { @@ -158,7 +158,7 @@ async fn allowlisted_static_keys_send_sigv4_and_denied_destination_sends_nothing .await?; let request = AwsRequestToSign { url: url.clone(), - ..super::tests::test_request() + ..crate::tests::test_request() }; let signed = context.sign(request.clone()).await?; let client = codex_http_client::HttpClientBuilder::new().build_with_tls( @@ -296,7 +296,7 @@ async fn sdk_credentials_preserve_profile_processes_and_environment_precedence() .args([ "--ignored", "--exact", - "network_policy_tests::sdk_profile_probe", + "tests::network_policy_tests::sdk_profile_probe", ]) .env("CODEX_AWS_PROFILE_PROBE_CASE", case) .env("AWS_CONFIG_FILE", config) @@ -363,7 +363,7 @@ async fn sdk_default_credentials_keep_endpoint_modes_from_environment_and_profil .args([ "--ignored", "--exact", - "network_policy_tests::sdk_profile_probe", + "tests::network_policy_tests::sdk_profile_probe", ]) .env("CODEX_AWS_PROFILE_PROBE_CASE", case) .env("AWS_CONFIG_FILE", config) @@ -437,7 +437,7 @@ fn sdk_profile_probe() -> Result<(), Box> { } else { AwsAuthContext::load_profile(config, factory).await? }; - let signed = context.sign(super::tests::test_request()).await; + let signed = context.sign(crate::tests::test_request()).await; if case.starts_with("endpoint-modes-") { assert!( matches!(signed, Err(AwsAuthError::Credentials(_))), diff --git a/codex-rs/ext/mcp/src/lib.rs b/codex-rs/ext/mcp/src/lib.rs index 2ca9e12a8a..e8a46b6dd5 100644 --- a/codex-rs/ext/mcp/src/lib.rs +++ b/codex-rs/ext/mcp/src/lib.rs @@ -8,9 +8,6 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::hosted_plugin_runtime_mcp_server_config; mod cloud_plugin; -#[cfg(test)] -#[path = "event_stream_tests.rs"] -mod event_stream_tests; mod plugin; mod plugin_contributor; mod plugin_contributor_state; @@ -29,10 +26,6 @@ pub use plugin_providers::PluginProviders; pub use stream_manager::McpEventStreamManager; pub use stream_manager::McpEventStreamUpdate; -#[cfg(test)] -#[path = "stream_manager_tests.rs"] -mod stream_manager_tests; - struct HostedPluginRuntimeExtension; impl McpServerContributor for HostedPluginRuntimeExtension { diff --git a/codex-rs/ext/mcp/src/lib_tests.rs b/codex-rs/ext/mcp/src/lib_tests.rs index 6ce1f07f04..1ada27e51e 100644 --- a/codex-rs/ext/mcp/src/lib_tests.rs +++ b/codex-rs/ext/mcp/src/lib_tests.rs @@ -54,3 +54,8 @@ async fn hosted_plugin_runtime_forwards_thread_originator() -> Result<(), Box, } -#[cfg(test)] -#[path = "rollout_attachment_tests.rs"] -mod rollout_attachment_tests; - impl FeedbackAttachmentPath { /// Read a whole regular file within the caller's size limit. pub fn read_attachment(&self, max_bytes: usize) -> io::Result> { @@ -1073,837 +1069,5 @@ impl Visit for FeedbackTagsVisitor { } #[cfg(test)] -#[path = "metadata_tests.rs"] -mod metadata_tests; - -#[cfg(test)] -#[path = "feedback_event_tests.rs"] -mod feedback_event_tests; - -#[cfg(test)] -mod tests { - use std::ffi::OsStr; - use std::fs; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use std::time::Duration; - - use super::*; - use crate::FeedbackDiagnostic; - use codex_http_client::OutboundProxyPolicy; - use flate2::Compression; - use flate2::read::GzDecoder; - use flate2::write::GzEncoder; - use http::StatusCode; - use pretty_assertions::assert_eq; - use tracing_subscriber::layer::SubscriberExt; - use tracing_subscriber::util::SubscriberInitExt; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::header_exists; - use wiremock::matchers::method; - use wiremock::matchers::path; - - #[test] - fn ring_buffer_drops_front_when_full() { - let fb = CodexFeedback::with_capacity(/*max_bytes*/ 8); - { - let mut w = fb.make_writer().make_writer(); - w.write_all(b"abcdefgh").unwrap(); - w.write_all(b"ij").unwrap(); - } - let snap = fb.snapshot(/*session_id*/ None); - // Capacity 8: after writing 10 bytes, we should keep the last 8. - pretty_assertions::assert_eq!(std::str::from_utf8(&snap.bytes).unwrap(), "cdefghij"); - } - - #[test] - fn logger_layer_filters_noisy_trace_payloads() { - let fb = CodexFeedback::new(); - let _guard = tracing_subscriber::registry() - // Keep another TRACE subscriber interested so bridged records are - // emitted; feedback must still reject them with its own filter. - .with(tracing_subscriber::fmt::layer().with_writer(std::io::sink)) - .with(fb.logger_layer()) - .set_default(); - - tracing::trace!(target: "codex_api::responses_websocket_timing", payload = "secret"); - tracing::event!( - target: "codex_otel.log_only", tracing::Level::INFO, - event.name = "codex.agent_response", response = "private-agent-response" - ); - tracing::info!(target: "codex_otel.log_only", rationale = "private-guardian-rationale"); - tracing::trace!(target: "codex_http_client::transport", "transport-trace"); - tracing::trace!(target: "codex_api::sse", "sse-trace"); - tracing::trace!(target: "codex_api::sse::responses", "nested-sse-trace"); - tracing::debug!(target: "codex_http_client::transport", "transport-debug"); - tracing::debug!(target: "codex_api::sse::responses", "sse-debug"); - tracing::trace!(target: "codex_feedback_test", "unrelated-trace"); - log::trace!(target: "codex_feedback_test", "unrelated-log-trace"); - log::trace!( - target: "tungstenite::handshake::client", - "websocket-handshake-payload" - ); - log::trace!(target: "tungstenite::protocol", "websocket-frame-payload"); - log::debug!(target: "tungstenite::protocol", "websocket-debug"); - - let logs = String::from_utf8(fb.snapshot(/*session_id*/ None).bytes).unwrap(); - for excluded in [ - "secret", - "private-agent-response", - "private-guardian-rationale", - "transport-trace", - "sse-trace", - "nested-sse-trace", - "websocket-handshake-payload", - "websocket-frame-payload", - ] { - assert!(!logs.contains(excluded)); - } - for retained in [ - "transport-debug", - "sse-debug", - "unrelated-trace", - "unrelated-log-trace", - "websocket-debug", - ] { - assert!(logs.contains(retained)); - } - } - - #[test] - fn metadata_layer_records_tags_from_feedback_target() { - let fb = CodexFeedback::new(); - let _guard = tracing_subscriber::registry() - .with(fb.metadata_layer()) - .set_default(); - - tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags"); - - let snap = fb.snapshot(/*session_id*/ None); - pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5")); - pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true")); - } - - async fn upload_test_feedback( - feedback: &CodexFeedback, - dsn: &str, - extra_attachments: Vec, - ) -> Result<()> { - feedback - .snapshot(/*session_id*/ None) - .with_feedback_diagnostics(FeedbackDiagnostics::default()) - .upload_feedback_with_dsn( - FeedbackUploadOptions { - classification: "bug", - reason: Some("private feedback"), - tags: None, - include_logs: true, - extra_attachments, - extra_attachment_paths: &[], - session_source: Some(SessionSource::Cli), - logs_override: Some(b"private log contents".to_vec()), - }, - &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - dsn, - Instant::now() + UPLOAD_TIMEOUT, - ) - .await - } - - #[tokio::test] - async fn feedback_upload_allows_slow_reports() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .respond_with( - ResponseTemplate::new(StatusCode::OK) - .set_delay(Duration::from_secs(/*secs*/ 4)), - ) - .expect(/*r*/ 3) - .mount(&server) - .await; - - let dsn = format!("http://public@{}/42", server.address()); - let attachment = FeedbackAttachment { - filename: "later.txt".to_string(), - content_type: None, - buffer: b"later diagnostic".to_vec(), - }; - upload_test_feedback(&CodexFeedback::new(), &dsn, vec![attachment]) - .await - .expect("all three envelopes should finish across twelve seconds of network waits"); - } - - #[tokio::test] - async fn feedback_upload_deadline_stops_retries_and_later_attachments() { - let server = MockServer::start().await; - let attempt = AtomicUsize::default(); - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .respond_with(move |_: &wiremock::Request| { - match attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) { - 0 | 2 => ResponseTemplate::new(StatusCode::OK) - .set_delay(Duration::from_secs(/*secs*/ 1)), - 1 => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) - .set_delay(Duration::from_secs(/*secs*/ 1)), - _ => ResponseTemplate::new(StatusCode::OK) - .set_delay(Duration::from_secs(/*secs*/ 20)), - } - }) - .expect(/*r*/ 4) - .mount(&server) - .await; - - let dsn = format!("http://public@{}/42", server.address()); - let attachments = ["stalled.txt", "later.txt"].map(|filename| FeedbackAttachment { - filename: filename.to_string(), - content_type: None, - buffer: filename.as_bytes().to_vec(), - }); - let snapshot = CodexFeedback::new() - .snapshot(/*session_id*/ None) - .with_feedback_diagnostics(FeedbackDiagnostics::default()); - let error = tokio::time::timeout( - Duration::from_secs(/*secs*/ 10), - snapshot.upload_feedback_with_dsn( - FeedbackUploadOptions { - classification: "bug", - reason: None, - tags: None, - include_logs: true, - extra_attachments: attachments.into(), - extra_attachment_paths: &[], - session_source: Some(SessionSource::Cli), - logs_override: Some(b"log contents".to_vec()), - }, - &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - &dsn, - Instant::now() + Duration::from_secs(/*secs*/ 8), - ), - ) - .await - .expect("each request must use only the time left in the report deadline") - .expect_err("the accepted report must report incomplete attachments"); - assert_eq!( - error.to_string(), - "feedback report was accepted, but some attachments failed to upload" - ); - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 4); - assert_eq!(requests[1].body, requests[2].body); - } - - #[tokio::test] - async fn feedback_upload_retries_diagnostics_without_replaying_core() { - let server = MockServer::start().await; - let attempt = AtomicUsize::default(); - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .and(header_exists("X-Sentry-Auth")) - .and(header("Content-Encoding", "gzip")) - .respond_with(move |_: &wiremock::Request| { - match attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) { - // Accept the core once; accept logs only on their second attempt. - 0 => ResponseTemplate::new(StatusCode::OK).insert_header( - "X-Sentry-Rate-Limits", - "0:attachment:project,60:transaction:project", - ), - 2 => ResponseTemplate::new(StatusCode::OK).insert_header("Retry-After", "0"), - 6 => ResponseTemplate::new(StatusCode::OK) - .insert_header("X-Sentry-Rate-Limits", "60:transaction:project"), - 1 => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) - .insert_header("Retry-After", "2"), - 5 => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) - .insert_header("Retry-After", "8"), - _ => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) - .insert_header("Retry-After", "0"), - } - }) - .expect(/*r*/ 7) - .mount(&server) - .await; - - let dsn = format!("http://public@{}/42", server.address()); - let attachments = ["retry-exhausted.txt", "later.txt"].map(|filename| FeedbackAttachment { - filename: filename.to_string(), - content_type: Some("text/plain".to_string()), - buffer: filename.as_bytes().to_vec(), - }); - let started = Instant::now(); - upload_test_feedback(&CodexFeedback::new(), &dsn, attachments.into()) - .await - .expect_err("legacy uploads report incomplete diagnostics"); - assert!(started.elapsed() >= Duration::from_secs(2)); - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[1].body, requests[2].body, - "retry exact request bytes" - ); - // Exhausting one diagnostic must not delay later.txt or replay earlier files. - assert!( - requests[3..6] - .iter() - .all(|request| request.body == requests[3].body) - ); - let mut decoded = String::new(); - GzDecoder::new(requests[1].body.as_slice()) - .read_to_string(&mut decoded) - .unwrap(); - assert!(decoded.ends_with("private log contents\n")); - } - - #[tokio::test] - async fn feedback_upload_delivers_large_files_intact() { - let suffix = ThreadId::new(); - let first_path = std::env::temp_dir().join(format!("feedback-first-{suffix}.jsonl")); - let second_path = std::env::temp_dir().join(format!("feedback-second-{suffix}.jsonl")); - let binary_path = std::env::temp_dir().join(format!("feedback-archive-{suffix}.jsonl.gz")); - let pending_path = second_path.with_extension("pending"); - let block = "{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"diagnostic fixture 🍵\"}}\n" - .repeat(1024); - let block_counts = [65, 80].map(|mib| mib * 1024 * 1024 / block.len() + 1); - for (path, blocks) in [&first_path, &pending_path].into_iter().zip(block_counts) { - let mut file = fs::File::create(path).unwrap(); - for _ in 0..blocks { - file.write_all(block.as_bytes()).unwrap(); - } - } - // Repeats beyond gzip's window keep the archive above the former 38.1 MiB cap. - let record_block = (0..4096) - .flat_map(|_| sentry::types::Uuid::new_v4().into_bytes()) - .map(|byte| { - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - [usize::from(byte % 64)] - }) - .collect::>(); - let mut file = GzEncoder::new(fs::File::create(&binary_path).unwrap(), Compression::fast()); - for mib in [32, 30] { - file.write_all(b"{\"message\":\"").unwrap(); - for _ in 0..mib * 1024 * 1024 / record_block.len() { - file.write_all(&record_block).unwrap(); - } - file.write_all(b"\"}\n").unwrap(); - } - file.finish().unwrap(); - assert!(fs::metadata(&binary_path).unwrap().len() > 40_000_000); - - let server = MockServer::start().await; - let attempt = AtomicUsize::default(); - let ready_path = second_path.clone(); - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .respond_with(move |_: &wiremock::Request| { - if attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) == 1 { - // The next file becomes readable only after the first file arrives. - fs::rename(&pending_path, &ready_path).unwrap(); - } - ResponseTemplate::new(StatusCode::OK) - }) - .expect(/*r*/ 4) - .mount(&server) - .await; - - CodexFeedback::new() - .snapshot(/*session_id*/ None) - .upload_feedback_with_dsn( - FeedbackUploadOptions { - classification: "bug", - reason: Some("large diagnostic upload"), - tags: None, - include_logs: false, - extra_attachments: Vec::new(), - extra_attachment_paths: &[ - FeedbackAttachmentPath { - path: first_path.clone(), - attachment_filename_override: None, - }, - FeedbackAttachmentPath { - path: second_path.clone(), - attachment_filename_override: None, - }, - FeedbackAttachmentPath { - path: binary_path.clone(), - attachment_filename_override: None, - }, - ], - session_source: Some(SessionSource::Cli), - logs_override: None, - }, - &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - &format!("http://public@{}/42", server.address()), - Instant::now() + UPLOAD_TIMEOUT, - ) - .await - .unwrap(); - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 4); - let mut event = String::new(); - GzDecoder::new(requests[0].body.as_slice()) - .read_to_string(&mut event) - .unwrap(); - for (request, (filename, source_path)) in requests[1..].iter().zip([ - (format!("feedback-first-{suffix}.jsonl"), &first_path), - (format!("feedback-second-{suffix}.jsonl"), &second_path), - (format!("feedback-archive-{suffix}.jsonl.gz"), &binary_path), - ]) { - let mut decoded = Vec::new(); - if request.headers.contains_key("Content-Encoding") { - GzDecoder::new(request.body.as_slice()) - .read_to_end(&mut decoded) - .unwrap(); - } else { - decoded.extend_from_slice(&request.body); - } - assert!(decoded.len() <= MAX_DECODED_UPLOAD_BYTES); - let mut parts = decoded.splitn(3, |byte| *byte == b'\n'); - assert_eq!( - parts.next().unwrap(), - event.lines().next().unwrap().as_bytes() - ); - let header = std::str::from_utf8(parts.next().unwrap()).unwrap(); - assert!(header.contains(&format!("\"filename\":\"{filename}\""))); - let payload = parts.next().unwrap().strip_suffix(b"\n").unwrap(); - let mut source = fs::File::open(source_path).unwrap(); - let original_bytes = source.metadata().unwrap().len() as usize; - assert_eq!(payload.len(), original_bytes); - if filename.ends_with(".jsonl") { - let text = std::str::from_utf8(payload).unwrap(); - assert!(text.ends_with('\n')); - } else { - let mut archive = GzDecoder::new(payload); - io::copy(&mut archive, &mut io::sink()).expect("received archive must be intact"); - } - let mut expected = [0; 64 * 1024]; - for chunk in payload.chunks(expected.len()) { - source.read_exact(&mut expected[..chunk.len()]).unwrap(); - assert_eq!(chunk, &expected[..chunk.len()]); - } - } - fs::remove_file(first_path).unwrap(); - fs::remove_file(second_path).unwrap(); - fs::remove_file(binary_path).unwrap(); - } - - #[tokio::test] - async fn feedback_upload_reports_unreadable_or_oversized_attachments() { - let oversized_path = - std::env::temp_dir().join(format!("feedback-oversized-{}", ThreadId::new())); - fs::File::create(&oversized_path) - .unwrap() - .set_len(MAX_DECODED_UPLOAD_BYTES as u64 + 1) - .unwrap(); - let server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(StatusCode::OK)) - .expect(/*r*/ 2) - .mount(&server) - .await; - - for path in [ - oversized_path.clone(), - oversized_path.with_extension("missing"), - ] { - let result = CodexFeedback::new() - .snapshot(/*session_id*/ None) - .upload_feedback_with_dsn( - FeedbackUploadOptions { - classification: "bug", - reason: None, - tags: None, - include_logs: false, - extra_attachments: Vec::new(), - extra_attachment_paths: &[FeedbackAttachmentPath { - path, - attachment_filename_override: None, - }], - session_source: Some(SessionSource::Cli), - logs_override: None, - }, - &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), - &format!("http://public@{}/42", server.address()), - Instant::now() + UPLOAD_TIMEOUT, - ) - .await; - assert_eq!( - result.unwrap_err().to_string(), - "feedback report was accepted, but some attachments failed to upload" - ); - } - assert_eq!( - fs::metadata(&oversized_path).unwrap().len(), - MAX_DECODED_UPLOAD_BYTES as u64 + 1 - ); - fs::remove_file(oversized_path).unwrap(); - } - - #[tokio::test] - async fn feedback_upload_stops_diagnostics_for_sentry_rate_limits() { - for (accepted_before_limit, rate_limit) in [ - (1, ResponseTemplate::new(StatusCode::TOO_MANY_REQUESTS)), - ( - 1, - ResponseTemplate::new(StatusCode::TOO_MANY_REQUESTS) - .insert_header("X-Sentry-Rate-Limits", "60:attachment:project") - .insert_header("Retry-After", "0"), - ), - ( - 0, - ResponseTemplate::new(StatusCode::OK) - .insert_header("X-Sentry-Rate-Limits", "60:attachment:project"), - ), - ( - 1, - ResponseTemplate::new(StatusCode::OK) - .insert_header("X-Sentry-Rate-Limits", "60::project"), - ), - ( - 0, - ResponseTemplate::new(StatusCode::OK).insert_header("Retry-After", "60"), - ), - ( - 1, - ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) - .insert_header("Retry-After", "300"), - ), - ( - 1, - ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) - .insert_header("Retry-After", "invalid"), - ), - ] { - let server = MockServer::start().await; - let attempt = AtomicUsize::default(); - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .respond_with(move |_: &wiremock::Request| { - if attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) < accepted_before_limit { - ResponseTemplate::new(StatusCode::OK) - } else { - rate_limit.clone() - } - }) - .expect((accepted_before_limit + 1) as u64) - .mount(&server) - .await; - - let dsn = format!("http://public@{}/42", server.address()); - let later = FeedbackAttachment { - filename: "later.txt".to_string(), - content_type: None, - buffer: b"later diagnostic".to_vec(), - }; - upload_test_feedback(&CodexFeedback::new(), &dsn, vec![later]) - .await - .expect_err("legacy uploads report rate-limited diagnostics"); - } - } - - #[tokio::test] - async fn feedback_upload_reports_rejected_sentry_response_without_exposing_feedback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .respond_with(ResponseTemplate::new(503)) - .expect(1) - .mount(&server) - .await; - - let feedback = CodexFeedback::new(); - let dsn = format!("http://public@{}/42", server.address()); - - let error = upload_test_feedback(&feedback, &dsn, Vec::new()) - .await - .expect_err("rejected Sentry responses must fail feedback uploads"); - - let error = format!("{error:#}"); - assert!(error.contains("503")); - assert!(!error.contains("private feedback")); - } - - #[tokio::test] - async fn feedback_upload_does_not_forward_private_data_to_redirect_target() { - let sentry_server = MockServer::start().await; - let redirect_target = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/42/envelope/")) - .respond_with( - ResponseTemplate::new(307) - .insert_header("Location", format!("{}/capture", redirect_target.uri())), - ) - .expect(1) - .mount(&sentry_server) - .await; - let dsn = format!("http://public@{}/42", sentry_server.address()); - let error = upload_test_feedback(&CodexFeedback::new(), &dsn, Vec::new()) - .await - .expect_err("redirected feedback uploads must be rejected"); - - assert!(error.to_string().contains("307")); - assert!( - redirect_target - .received_requests() - .await - .expect("redirect target should record requests") - .is_empty() - ); - } - - #[tokio::test] - async fn feedback_upload_reports_transport_failures() { - let listener = std::net::TcpListener::bind("127.0.0.1:0") - .expect("unused local address should be available"); - let address = listener - .local_addr() - .expect("listener should have an address"); - drop(listener); - - let dsn = format!("http://public@{address}/42"); - let error = upload_test_feedback(&CodexFeedback::new(), &dsn, Vec::new()) - .await - .expect_err("transport failures must fail feedback uploads"); - - assert!( - error - .downcast_ref::() - .is_some_and(codex_http_client::RouteAwareRequestError::is_connect) - ); - } - - #[test] - fn feedback_attachments_gate_connectivity_diagnostics() { - let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new()); - let extra_path = std::env::temp_dir().join(&extra_filename); - let extra_attachment_path = FeedbackAttachmentPath { - path: extra_path.clone(), - attachment_filename_override: None, - }; - fs::write(&extra_path, "rollout").expect("extra attachment should be written"); - - let snapshot_with_diagnostics = CodexFeedback::new() - .snapshot(/*session_id*/ None) - .with_feedback_diagnostics(FeedbackDiagnostics::new(vec![FeedbackDiagnostic { - headline: "Proxy environment variables are set and may affect connectivity." - .to_string(), - details: vec!["HTTPS_PROXY = https://example.com:443".to_string()], - }])); - - let attachments_with_diagnostics = snapshot_with_diagnostics - .feedback_attachments( - &sentry::protocol::EnvelopeHeaders::default(), - /*include_logs*/ true, - vec![FeedbackAttachment { - filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), - content_type: Some("application/json".to_string()), - buffer: b"{\"overallStatus\":\"ok\"}".to_vec(), - }], - &[extra_attachment_path], - Some(vec![1]), - ) - .collect::>>() - .unwrap(); - - assert_eq!( - attachments_with_diagnostics - .iter() - .map(|attachment| attachment.filename.as_str()) - .collect::>(), - vec![ - "codex-logs.log", - DOCTOR_REPORT_ATTACHMENT_FILENAME, - FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, - extra_filename.as_str() - ] - ); - assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); - assert_eq!( - attachments_with_diagnostics[1].buffer, - b"{\"overallStatus\":\"ok\"}".to_vec() - ); - assert_eq!( - attachments_with_diagnostics[2].buffer, - b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec() - ); - assert_eq!(attachments_with_diagnostics[3].buffer, b"rollout".to_vec()); - assert_eq!( - attachments_with_diagnostics[3].content_type.as_deref(), - Some("text/plain") - ); - assert_eq!( - OsStr::new(attachments_with_diagnostics[3].filename.as_str()), - OsStr::new(extra_filename.as_str()) - ); - let attachments_without_diagnostics = CodexFeedback::new() - .snapshot(/*session_id*/ None) - .with_feedback_diagnostics(FeedbackDiagnostics::default()) - .feedback_attachments( - &sentry::protocol::EnvelopeHeaders::default(), - /*include_logs*/ true, - Vec::new(), - &[], - Some(vec![1]), - ) - .collect::>>() - .unwrap(); - - assert_eq!( - attachments_without_diagnostics - .iter() - .map(|attachment| attachment.filename.as_str()) - .collect::>(), - vec!["codex-logs.log"] - ); - assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]); - fs::remove_file(extra_path).expect("extra attachment should be removed"); - } - - #[test] - fn path_backed_attachments_use_binary_content_types() { - let suffix = ThreadId::new(); - let gzip_filename = format!("codex-desktop-app-logs-{suffix}.tar.gz"); - let unknown_filename = format!("codex-feedback-extra-{suffix}.binunknown"); - let gzip_path = std::env::temp_dir().join(&gzip_filename); - let unknown_path = std::env::temp_dir().join(&unknown_filename); - let gzip_bytes = b"\x1f\x8b\x08\x00\xff"; - let unknown_bytes = b"\x00\x9f\x92\x96"; - fs::write(&gzip_path, gzip_bytes).expect("gzip attachment should be written"); - fs::write(&unknown_path, unknown_bytes).expect("unknown attachment should be written"); - - let attachments = CodexFeedback::new() - .snapshot(/*session_id*/ None) - .feedback_attachments( - &sentry::protocol::EnvelopeHeaders::default(), - /*include_logs*/ false, - Vec::new(), - &[ - FeedbackAttachmentPath { - path: gzip_path.clone(), - attachment_filename_override: None, - }, - FeedbackAttachmentPath { - path: unknown_path.clone(), - attachment_filename_override: None, - }, - ], - /*logs_override*/ None, - ) - .collect::>>() - .unwrap(); - - fs::remove_file(gzip_path).expect("gzip attachment should be removed"); - fs::remove_file(unknown_path).expect("unknown attachment should be removed"); - assert_eq!( - attachments - .iter() - .map(|attachment| ( - attachment.filename.as_str(), - attachment.content_type.as_deref(), - attachment.buffer.as_slice(), - )) - .collect::>(), - vec![ - ( - gzip_filename.as_str(), - Some("application/gzip"), - gzip_bytes.as_slice(), - ), - ( - unknown_filename.as_str(), - Some("application/octet-stream"), - unknown_bytes.as_slice(), - ), - ] - ); - } - - #[test] - fn upload_tags_include_client_tags_and_preserve_reserved_fields() { - let mut tags = BTreeMap::new(); - tags.insert("thread_id".to_string(), "wrong-thread".to_string()); - tags.insert("turn_id".to_string(), "wrong-turn".to_string()); - tags.insert( - "classification".to_string(), - "wrong-classification".to_string(), - ); - tags.insert("cli_version".to_string(), "wrong-version".to_string()); - tags.insert("session_source".to_string(), "wrong-source".to_string()); - tags.insert("reason".to_string(), "wrong-reason".to_string()); - tags.insert("account_id".to_string(), "actual-account".to_string()); - tags.insert("model".to_string(), "gpt-5".to_string()); - tags.insert("effort".to_string(), "Some(High)".to_string()); - let snapshot = FeedbackSnapshot { - bytes: Vec::new(), - tags, - feedback_diagnostics: FeedbackDiagnostics::default(), - thread_id: "thread-123".to_string(), - }; - let mut client_tags = BTreeMap::new(); - client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string()); - client_tags.insert("turn_id".to_string(), "turn-456".to_string()); - client_tags.insert( - "classification".to_string(), - "wrong-client-classification".to_string(), - ); - client_tags.insert( - "cli_version".to_string(), - "wrong-client-version".to_string(), - ); - client_tags.insert( - "session_source".to_string(), - "wrong-client-source".to_string(), - ); - client_tags.insert("reason".to_string(), "wrong-client-reason".to_string()); - client_tags.insert("client_tag".to_string(), "from-client".to_string()); - client_tags.insert("model".to_string(), "mewthree".to_string()); - client_tags.insert("effort".to_string(), "Some(Ultra)".to_string()); - - let upload_tags = snapshot.upload_tags( - "bug", - Some("actual reason"), - Some(&client_tags), - Some(&SessionSource::Cli), - ); - - assert_eq!( - upload_tags.get("thread_id").map(String::as_str), - Some("thread-123") - ); - assert_eq!( - upload_tags.get("turn_id").map(String::as_str), - Some("turn-456") - ); - assert_eq!( - upload_tags.get("classification").map(String::as_str), - Some("bug") - ); - assert_eq!( - upload_tags.get("session_source").map(String::as_str), - Some("cli") - ); - assert_eq!( - upload_tags.get("reason").map(String::as_str), - Some("actual reason") - ); - assert_eq!( - upload_tags.get("account_id").map(String::as_str), - Some("actual-account") - ); - assert_eq!( - upload_tags.get("model").map(String::as_str), - Some("mewthree") - ); - assert_eq!( - upload_tags.get("effort").map(String::as_str), - Some("Some(Ultra)") - ); - assert_eq!( - upload_tags.get("client_tag").map(String::as_str), - Some("from-client") - ); - } -} - -#[cfg(test)] -mod save_rollout_tests; +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/feedback/src/lib_tests.rs b/codex-rs/feedback/src/lib_tests.rs new file mode 100644 index 0000000000..9e21368d04 --- /dev/null +++ b/codex-rs/feedback/src/lib_tests.rs @@ -0,0 +1,833 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +use std::ffi::OsStr; +use std::fs; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use super::*; +use crate::FeedbackDiagnostic; +use codex_http_client::OutboundProxyPolicy; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use http::StatusCode; +use pretty_assertions::assert_eq; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::header_exists; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[test] +fn ring_buffer_drops_front_when_full() { + let fb = CodexFeedback::with_capacity(/*max_bytes*/ 8); + { + let mut w = fb.make_writer().make_writer(); + w.write_all(b"abcdefgh").unwrap(); + w.write_all(b"ij").unwrap(); + } + let snap = fb.snapshot(/*session_id*/ None); + // Capacity 8: after writing 10 bytes, we should keep the last 8. + pretty_assertions::assert_eq!(std::str::from_utf8(&snap.bytes).unwrap(), "cdefghij"); +} + +#[test] +fn logger_layer_filters_noisy_trace_payloads() { + let fb = CodexFeedback::new(); + let _guard = tracing_subscriber::registry() + // Keep another TRACE subscriber interested so bridged records are + // emitted; feedback must still reject them with its own filter. + .with(tracing_subscriber::fmt::layer().with_writer(std::io::sink)) + .with(fb.logger_layer()) + .set_default(); + + tracing::trace!(target: "codex_api::responses_websocket_timing", payload = "secret"); + tracing::event!( + target: "codex_otel.log_only", tracing::Level::INFO, + event.name = "codex.agent_response", response = "private-agent-response" + ); + tracing::info!(target: "codex_otel.log_only", rationale = "private-guardian-rationale"); + tracing::trace!(target: "codex_http_client::transport", "transport-trace"); + tracing::trace!(target: "codex_api::sse", "sse-trace"); + tracing::trace!(target: "codex_api::sse::responses", "nested-sse-trace"); + tracing::debug!(target: "codex_http_client::transport", "transport-debug"); + tracing::debug!(target: "codex_api::sse::responses", "sse-debug"); + tracing::trace!(target: "codex_feedback_test", "unrelated-trace"); + log::trace!(target: "codex_feedback_test", "unrelated-log-trace"); + log::trace!( + target: "tungstenite::handshake::client", + "websocket-handshake-payload" + ); + log::trace!(target: "tungstenite::protocol", "websocket-frame-payload"); + log::debug!(target: "tungstenite::protocol", "websocket-debug"); + + let logs = String::from_utf8(fb.snapshot(/*session_id*/ None).bytes).unwrap(); + for excluded in [ + "secret", + "private-agent-response", + "private-guardian-rationale", + "transport-trace", + "sse-trace", + "nested-sse-trace", + "websocket-handshake-payload", + "websocket-frame-payload", + ] { + assert!(!logs.contains(excluded)); + } + for retained in [ + "transport-debug", + "sse-debug", + "unrelated-trace", + "unrelated-log-trace", + "websocket-debug", + ] { + assert!(logs.contains(retained)); + } +} + +#[test] +fn metadata_layer_records_tags_from_feedback_target() { + let fb = CodexFeedback::new(); + let _guard = tracing_subscriber::registry() + .with(fb.metadata_layer()) + .set_default(); + + tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags"); + + let snap = fb.snapshot(/*session_id*/ None); + pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5")); + pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true")); +} + +async fn upload_test_feedback( + feedback: &CodexFeedback, + dsn: &str, + extra_attachments: Vec, +) -> Result<()> { + feedback + .snapshot(/*session_id*/ None) + .with_feedback_diagnostics(FeedbackDiagnostics::default()) + .upload_feedback_with_dsn( + FeedbackUploadOptions { + classification: "bug", + reason: Some("private feedback"), + tags: None, + include_logs: true, + extra_attachments, + extra_attachment_paths: &[], + session_source: Some(SessionSource::Cli), + logs_override: Some(b"private log contents".to_vec()), + }, + &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + dsn, + Instant::now() + UPLOAD_TIMEOUT, + ) + .await +} + +#[tokio::test] +async fn feedback_upload_allows_slow_reports() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .respond_with( + ResponseTemplate::new(StatusCode::OK).set_delay(Duration::from_secs(/*secs*/ 4)), + ) + .expect(/*r*/ 3) + .mount(&server) + .await; + + let dsn = format!("http://public@{}/42", server.address()); + let attachment = FeedbackAttachment { + filename: "later.txt".to_string(), + content_type: None, + buffer: b"later diagnostic".to_vec(), + }; + upload_test_feedback(&CodexFeedback::new(), &dsn, vec![attachment]) + .await + .expect("all three envelopes should finish across twelve seconds of network waits"); +} + +#[tokio::test] +async fn feedback_upload_deadline_stops_retries_and_later_attachments() { + let server = MockServer::start().await; + let attempt = AtomicUsize::default(); + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .respond_with(move |_: &wiremock::Request| { + match attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) { + 0 | 2 => ResponseTemplate::new(StatusCode::OK) + .set_delay(Duration::from_secs(/*secs*/ 1)), + 1 => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) + .set_delay(Duration::from_secs(/*secs*/ 1)), + _ => ResponseTemplate::new(StatusCode::OK) + .set_delay(Duration::from_secs(/*secs*/ 20)), + } + }) + .expect(/*r*/ 4) + .mount(&server) + .await; + + let dsn = format!("http://public@{}/42", server.address()); + let attachments = ["stalled.txt", "later.txt"].map(|filename| FeedbackAttachment { + filename: filename.to_string(), + content_type: None, + buffer: filename.as_bytes().to_vec(), + }); + let snapshot = CodexFeedback::new() + .snapshot(/*session_id*/ None) + .with_feedback_diagnostics(FeedbackDiagnostics::default()); + let error = tokio::time::timeout( + Duration::from_secs(/*secs*/ 10), + snapshot.upload_feedback_with_dsn( + FeedbackUploadOptions { + classification: "bug", + reason: None, + tags: None, + include_logs: true, + extra_attachments: attachments.into(), + extra_attachment_paths: &[], + session_source: Some(SessionSource::Cli), + logs_override: Some(b"log contents".to_vec()), + }, + &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + &dsn, + Instant::now() + Duration::from_secs(/*secs*/ 8), + ), + ) + .await + .expect("each request must use only the time left in the report deadline") + .expect_err("the accepted report must report incomplete attachments"); + assert_eq!( + error.to_string(), + "feedback report was accepted, but some attachments failed to upload" + ); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 4); + assert_eq!(requests[1].body, requests[2].body); +} + +#[tokio::test] +async fn feedback_upload_retries_diagnostics_without_replaying_core() { + let server = MockServer::start().await; + let attempt = AtomicUsize::default(); + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .and(header_exists("X-Sentry-Auth")) + .and(header("Content-Encoding", "gzip")) + .respond_with(move |_: &wiremock::Request| { + match attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) { + // Accept the core once; accept logs only on their second attempt. + 0 => ResponseTemplate::new(StatusCode::OK).insert_header( + "X-Sentry-Rate-Limits", + "0:attachment:project,60:transaction:project", + ), + 2 => ResponseTemplate::new(StatusCode::OK).insert_header("Retry-After", "0"), + 6 => ResponseTemplate::new(StatusCode::OK) + .insert_header("X-Sentry-Rate-Limits", "60:transaction:project"), + 1 => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) + .insert_header("Retry-After", "2"), + 5 => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) + .insert_header("Retry-After", "8"), + _ => ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) + .insert_header("Retry-After", "0"), + } + }) + .expect(/*r*/ 7) + .mount(&server) + .await; + + let dsn = format!("http://public@{}/42", server.address()); + let attachments = ["retry-exhausted.txt", "later.txt"].map(|filename| FeedbackAttachment { + filename: filename.to_string(), + content_type: Some("text/plain".to_string()), + buffer: filename.as_bytes().to_vec(), + }); + let started = Instant::now(); + upload_test_feedback(&CodexFeedback::new(), &dsn, attachments.into()) + .await + .expect_err("legacy uploads report incomplete diagnostics"); + assert!(started.elapsed() >= Duration::from_secs(2)); + let requests = server.received_requests().await.unwrap(); + assert_eq!( + requests[1].body, requests[2].body, + "retry exact request bytes" + ); + // Exhausting one diagnostic must not delay later.txt or replay earlier files. + assert!( + requests[3..6] + .iter() + .all(|request| request.body == requests[3].body) + ); + let mut decoded = String::new(); + GzDecoder::new(requests[1].body.as_slice()) + .read_to_string(&mut decoded) + .unwrap(); + assert!(decoded.ends_with("private log contents\n")); +} + +#[tokio::test] +async fn feedback_upload_delivers_large_files_intact() { + let suffix = ThreadId::new(); + let first_path = std::env::temp_dir().join(format!("feedback-first-{suffix}.jsonl")); + let second_path = std::env::temp_dir().join(format!("feedback-second-{suffix}.jsonl")); + let binary_path = std::env::temp_dir().join(format!("feedback-archive-{suffix}.jsonl.gz")); + let pending_path = second_path.with_extension("pending"); + let block = "{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"diagnostic fixture 🍵\"}}\n" + .repeat(1024); + let block_counts = [65, 80].map(|mib| mib * 1024 * 1024 / block.len() + 1); + for (path, blocks) in [&first_path, &pending_path].into_iter().zip(block_counts) { + let mut file = fs::File::create(path).unwrap(); + for _ in 0..blocks { + file.write_all(block.as_bytes()).unwrap(); + } + } + // Repeats beyond gzip's window keep the archive above the former 38.1 MiB cap. + let record_block = (0..4096) + .flat_map(|_| sentry::types::Uuid::new_v4().into_bytes()) + .map(|byte| { + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + [usize::from(byte % 64)] + }) + .collect::>(); + let mut file = GzEncoder::new(fs::File::create(&binary_path).unwrap(), Compression::fast()); + for mib in [32, 30] { + file.write_all(b"{\"message\":\"").unwrap(); + for _ in 0..mib * 1024 * 1024 / record_block.len() { + file.write_all(&record_block).unwrap(); + } + file.write_all(b"\"}\n").unwrap(); + } + file.finish().unwrap(); + assert!(fs::metadata(&binary_path).unwrap().len() > 40_000_000); + + let server = MockServer::start().await; + let attempt = AtomicUsize::default(); + let ready_path = second_path.clone(); + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .respond_with(move |_: &wiremock::Request| { + if attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) == 1 { + // The next file becomes readable only after the first file arrives. + fs::rename(&pending_path, &ready_path).unwrap(); + } + ResponseTemplate::new(StatusCode::OK) + }) + .expect(/*r*/ 4) + .mount(&server) + .await; + + CodexFeedback::new() + .snapshot(/*session_id*/ None) + .upload_feedback_with_dsn( + FeedbackUploadOptions { + classification: "bug", + reason: Some("large diagnostic upload"), + tags: None, + include_logs: false, + extra_attachments: Vec::new(), + extra_attachment_paths: &[ + FeedbackAttachmentPath { + path: first_path.clone(), + attachment_filename_override: None, + }, + FeedbackAttachmentPath { + path: second_path.clone(), + attachment_filename_override: None, + }, + FeedbackAttachmentPath { + path: binary_path.clone(), + attachment_filename_override: None, + }, + ], + session_source: Some(SessionSource::Cli), + logs_override: None, + }, + &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + &format!("http://public@{}/42", server.address()), + Instant::now() + UPLOAD_TIMEOUT, + ) + .await + .unwrap(); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 4); + let mut event = String::new(); + GzDecoder::new(requests[0].body.as_slice()) + .read_to_string(&mut event) + .unwrap(); + for (request, (filename, source_path)) in requests[1..].iter().zip([ + (format!("feedback-first-{suffix}.jsonl"), &first_path), + (format!("feedback-second-{suffix}.jsonl"), &second_path), + (format!("feedback-archive-{suffix}.jsonl.gz"), &binary_path), + ]) { + let mut decoded = Vec::new(); + if request.headers.contains_key("Content-Encoding") { + GzDecoder::new(request.body.as_slice()) + .read_to_end(&mut decoded) + .unwrap(); + } else { + decoded.extend_from_slice(&request.body); + } + assert!(decoded.len() <= MAX_DECODED_UPLOAD_BYTES); + let mut parts = decoded.splitn(3, |byte| *byte == b'\n'); + assert_eq!( + parts.next().unwrap(), + event.lines().next().unwrap().as_bytes() + ); + let header = std::str::from_utf8(parts.next().unwrap()).unwrap(); + assert!(header.contains(&format!("\"filename\":\"{filename}\""))); + let payload = parts.next().unwrap().strip_suffix(b"\n").unwrap(); + let mut source = fs::File::open(source_path).unwrap(); + let original_bytes = source.metadata().unwrap().len() as usize; + assert_eq!(payload.len(), original_bytes); + if filename.ends_with(".jsonl") { + let text = std::str::from_utf8(payload).unwrap(); + assert!(text.ends_with('\n')); + } else { + let mut archive = GzDecoder::new(payload); + io::copy(&mut archive, &mut io::sink()).expect("received archive must be intact"); + } + let mut expected = [0; 64 * 1024]; + for chunk in payload.chunks(expected.len()) { + source.read_exact(&mut expected[..chunk.len()]).unwrap(); + assert_eq!(chunk, &expected[..chunk.len()]); + } + } + fs::remove_file(first_path).unwrap(); + fs::remove_file(second_path).unwrap(); + fs::remove_file(binary_path).unwrap(); +} + +#[tokio::test] +async fn feedback_upload_reports_unreadable_or_oversized_attachments() { + let oversized_path = + std::env::temp_dir().join(format!("feedback-oversized-{}", ThreadId::new())); + fs::File::create(&oversized_path) + .unwrap() + .set_len(MAX_DECODED_UPLOAD_BYTES as u64 + 1) + .unwrap(); + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(StatusCode::OK)) + .expect(/*r*/ 2) + .mount(&server) + .await; + + for path in [ + oversized_path.clone(), + oversized_path.with_extension("missing"), + ] { + let result = CodexFeedback::new() + .snapshot(/*session_id*/ None) + .upload_feedback_with_dsn( + FeedbackUploadOptions { + classification: "bug", + reason: None, + tags: None, + include_logs: false, + extra_attachments: Vec::new(), + extra_attachment_paths: &[FeedbackAttachmentPath { + path, + attachment_filename_override: None, + }], + session_source: Some(SessionSource::Cli), + logs_override: None, + }, + &HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + &format!("http://public@{}/42", server.address()), + Instant::now() + UPLOAD_TIMEOUT, + ) + .await; + assert_eq!( + result.unwrap_err().to_string(), + "feedback report was accepted, but some attachments failed to upload" + ); + } + assert_eq!( + fs::metadata(&oversized_path).unwrap().len(), + MAX_DECODED_UPLOAD_BYTES as u64 + 1 + ); + fs::remove_file(oversized_path).unwrap(); +} + +#[tokio::test] +async fn feedback_upload_stops_diagnostics_for_sentry_rate_limits() { + for (accepted_before_limit, rate_limit) in [ + (1, ResponseTemplate::new(StatusCode::TOO_MANY_REQUESTS)), + ( + 1, + ResponseTemplate::new(StatusCode::TOO_MANY_REQUESTS) + .insert_header("X-Sentry-Rate-Limits", "60:attachment:project") + .insert_header("Retry-After", "0"), + ), + ( + 0, + ResponseTemplate::new(StatusCode::OK) + .insert_header("X-Sentry-Rate-Limits", "60:attachment:project"), + ), + ( + 1, + ResponseTemplate::new(StatusCode::OK) + .insert_header("X-Sentry-Rate-Limits", "60::project"), + ), + ( + 0, + ResponseTemplate::new(StatusCode::OK).insert_header("Retry-After", "60"), + ), + ( + 1, + ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) + .insert_header("Retry-After", "300"), + ), + ( + 1, + ResponseTemplate::new(StatusCode::SERVICE_UNAVAILABLE) + .insert_header("Retry-After", "invalid"), + ), + ] { + let server = MockServer::start().await; + let attempt = AtomicUsize::default(); + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .respond_with(move |_: &wiremock::Request| { + if attempt.fetch_add(/*val*/ 1, Ordering::SeqCst) < accepted_before_limit { + ResponseTemplate::new(StatusCode::OK) + } else { + rate_limit.clone() + } + }) + .expect((accepted_before_limit + 1) as u64) + .mount(&server) + .await; + + let dsn = format!("http://public@{}/42", server.address()); + let later = FeedbackAttachment { + filename: "later.txt".to_string(), + content_type: None, + buffer: b"later diagnostic".to_vec(), + }; + upload_test_feedback(&CodexFeedback::new(), &dsn, vec![later]) + .await + .expect_err("legacy uploads report rate-limited diagnostics"); + } +} + +#[tokio::test] +async fn feedback_upload_reports_rejected_sentry_response_without_exposing_feedback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + + let feedback = CodexFeedback::new(); + let dsn = format!("http://public@{}/42", server.address()); + + let error = upload_test_feedback(&feedback, &dsn, Vec::new()) + .await + .expect_err("rejected Sentry responses must fail feedback uploads"); + + let error = format!("{error:#}"); + assert!(error.contains("503")); + assert!(!error.contains("private feedback")); +} + +#[tokio::test] +async fn feedback_upload_does_not_forward_private_data_to_redirect_target() { + let sentry_server = MockServer::start().await; + let redirect_target = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/42/envelope/")) + .respond_with( + ResponseTemplate::new(307) + .insert_header("Location", format!("{}/capture", redirect_target.uri())), + ) + .expect(1) + .mount(&sentry_server) + .await; + let dsn = format!("http://public@{}/42", sentry_server.address()); + let error = upload_test_feedback(&CodexFeedback::new(), &dsn, Vec::new()) + .await + .expect_err("redirected feedback uploads must be rejected"); + + assert!(error.to_string().contains("307")); + assert!( + redirect_target + .received_requests() + .await + .expect("redirect target should record requests") + .is_empty() + ); +} + +#[tokio::test] +async fn feedback_upload_reports_transport_failures() { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("unused local address should be available"); + let address = listener + .local_addr() + .expect("listener should have an address"); + drop(listener); + + let dsn = format!("http://public@{address}/42"); + let error = upload_test_feedback(&CodexFeedback::new(), &dsn, Vec::new()) + .await + .expect_err("transport failures must fail feedback uploads"); + + assert!( + error + .downcast_ref::() + .is_some_and(codex_http_client::RouteAwareRequestError::is_connect) + ); +} + +#[test] +fn feedback_attachments_gate_connectivity_diagnostics() { + let extra_filename = format!("codex-feedback-extra-{}.jsonl", ThreadId::new()); + let extra_path = std::env::temp_dir().join(&extra_filename); + let extra_attachment_path = FeedbackAttachmentPath { + path: extra_path.clone(), + attachment_filename_override: None, + }; + fs::write(&extra_path, "rollout").expect("extra attachment should be written"); + + let snapshot_with_diagnostics = CodexFeedback::new() + .snapshot(/*session_id*/ None) + .with_feedback_diagnostics(FeedbackDiagnostics::new(vec![FeedbackDiagnostic { + headline: "Proxy environment variables are set and may affect connectivity." + .to_string(), + details: vec!["HTTPS_PROXY = https://example.com:443".to_string()], + }])); + + let attachments_with_diagnostics = snapshot_with_diagnostics + .feedback_attachments( + &sentry::protocol::EnvelopeHeaders::default(), + /*include_logs*/ true, + vec![FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: b"{\"overallStatus\":\"ok\"}".to_vec(), + }], + &[extra_attachment_path], + Some(vec![1]), + ) + .collect::>>() + .unwrap(); + + assert_eq!( + attachments_with_diagnostics + .iter() + .map(|attachment| attachment.filename.as_str()) + .collect::>(), + vec![ + "codex-logs.log", + DOCTOR_REPORT_ATTACHMENT_FILENAME, + FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, + extra_filename.as_str() + ] + ); + assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); + assert_eq!( + attachments_with_diagnostics[1].buffer, + b"{\"overallStatus\":\"ok\"}".to_vec() + ); + assert_eq!( + attachments_with_diagnostics[2].buffer, + b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec() + ); + assert_eq!(attachments_with_diagnostics[3].buffer, b"rollout".to_vec()); + assert_eq!( + attachments_with_diagnostics[3].content_type.as_deref(), + Some("text/plain") + ); + assert_eq!( + OsStr::new(attachments_with_diagnostics[3].filename.as_str()), + OsStr::new(extra_filename.as_str()) + ); + let attachments_without_diagnostics = CodexFeedback::new() + .snapshot(/*session_id*/ None) + .with_feedback_diagnostics(FeedbackDiagnostics::default()) + .feedback_attachments( + &sentry::protocol::EnvelopeHeaders::default(), + /*include_logs*/ true, + Vec::new(), + &[], + Some(vec![1]), + ) + .collect::>>() + .unwrap(); + + assert_eq!( + attachments_without_diagnostics + .iter() + .map(|attachment| attachment.filename.as_str()) + .collect::>(), + vec!["codex-logs.log"] + ); + assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]); + fs::remove_file(extra_path).expect("extra attachment should be removed"); +} + +#[test] +fn path_backed_attachments_use_binary_content_types() { + let suffix = ThreadId::new(); + let gzip_filename = format!("codex-desktop-app-logs-{suffix}.tar.gz"); + let unknown_filename = format!("codex-feedback-extra-{suffix}.binunknown"); + let gzip_path = std::env::temp_dir().join(&gzip_filename); + let unknown_path = std::env::temp_dir().join(&unknown_filename); + let gzip_bytes = b"\x1f\x8b\x08\x00\xff"; + let unknown_bytes = b"\x00\x9f\x92\x96"; + fs::write(&gzip_path, gzip_bytes).expect("gzip attachment should be written"); + fs::write(&unknown_path, unknown_bytes).expect("unknown attachment should be written"); + + let attachments = CodexFeedback::new() + .snapshot(/*session_id*/ None) + .feedback_attachments( + &sentry::protocol::EnvelopeHeaders::default(), + /*include_logs*/ false, + Vec::new(), + &[ + FeedbackAttachmentPath { + path: gzip_path.clone(), + attachment_filename_override: None, + }, + FeedbackAttachmentPath { + path: unknown_path.clone(), + attachment_filename_override: None, + }, + ], + /*logs_override*/ None, + ) + .collect::>>() + .unwrap(); + + fs::remove_file(gzip_path).expect("gzip attachment should be removed"); + fs::remove_file(unknown_path).expect("unknown attachment should be removed"); + assert_eq!( + attachments + .iter() + .map(|attachment| ( + attachment.filename.as_str(), + attachment.content_type.as_deref(), + attachment.buffer.as_slice(), + )) + .collect::>(), + vec![ + ( + gzip_filename.as_str(), + Some("application/gzip"), + gzip_bytes.as_slice(), + ), + ( + unknown_filename.as_str(), + Some("application/octet-stream"), + unknown_bytes.as_slice(), + ), + ] + ); +} + +#[test] +fn upload_tags_include_client_tags_and_preserve_reserved_fields() { + let mut tags = BTreeMap::new(); + tags.insert("thread_id".to_string(), "wrong-thread".to_string()); + tags.insert("turn_id".to_string(), "wrong-turn".to_string()); + tags.insert( + "classification".to_string(), + "wrong-classification".to_string(), + ); + tags.insert("cli_version".to_string(), "wrong-version".to_string()); + tags.insert("session_source".to_string(), "wrong-source".to_string()); + tags.insert("reason".to_string(), "wrong-reason".to_string()); + tags.insert("account_id".to_string(), "actual-account".to_string()); + tags.insert("model".to_string(), "gpt-5".to_string()); + tags.insert("effort".to_string(), "Some(High)".to_string()); + let snapshot = FeedbackSnapshot { + bytes: Vec::new(), + tags, + feedback_diagnostics: FeedbackDiagnostics::default(), + thread_id: "thread-123".to_string(), + }; + let mut client_tags = BTreeMap::new(); + client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string()); + client_tags.insert("turn_id".to_string(), "turn-456".to_string()); + client_tags.insert( + "classification".to_string(), + "wrong-client-classification".to_string(), + ); + client_tags.insert( + "cli_version".to_string(), + "wrong-client-version".to_string(), + ); + client_tags.insert( + "session_source".to_string(), + "wrong-client-source".to_string(), + ); + client_tags.insert("reason".to_string(), "wrong-client-reason".to_string()); + client_tags.insert("client_tag".to_string(), "from-client".to_string()); + client_tags.insert("model".to_string(), "mewthree".to_string()); + client_tags.insert("effort".to_string(), "Some(Ultra)".to_string()); + + let upload_tags = snapshot.upload_tags( + "bug", + Some("actual reason"), + Some(&client_tags), + Some(&SessionSource::Cli), + ); + + assert_eq!( + upload_tags.get("thread_id").map(String::as_str), + Some("thread-123") + ); + assert_eq!( + upload_tags.get("turn_id").map(String::as_str), + Some("turn-456") + ); + assert_eq!( + upload_tags.get("classification").map(String::as_str), + Some("bug") + ); + assert_eq!( + upload_tags.get("session_source").map(String::as_str), + Some("cli") + ); + assert_eq!( + upload_tags.get("reason").map(String::as_str), + Some("actual reason") + ); + assert_eq!( + upload_tags.get("account_id").map(String::as_str), + Some("actual-account") + ); + assert_eq!( + upload_tags.get("model").map(String::as_str), + Some("mewthree") + ); + assert_eq!( + upload_tags.get("effort").map(String::as_str), + Some("Some(Ultra)") + ); + assert_eq!( + upload_tags.get("client_tag").map(String::as_str), + Some("from-client") + ); +} + +#[path = "feedback_event_tests.rs"] +mod feedback_event_tests; +#[path = "metadata_tests.rs"] +mod metadata_tests; +#[path = "rollout_attachment_tests.rs"] +mod rollout_attachment_tests; +#[path = "save_rollout_tests.rs"] +mod save_rollout_tests; diff --git a/codex-rs/feedback/src/metadata_tests.rs b/codex-rs/feedback/src/metadata_tests.rs index 83f7cddbac..f214ccae3f 100644 --- a/codex-rs/feedback/src/metadata_tests.rs +++ b/codex-rs/feedback/src/metadata_tests.rs @@ -1,7 +1,7 @@ //! Exercise dynamic tag collection, upload serialization, and bounded updates. -use super::*; use crate::CodexFeedback; +use crate::*; use pretty_assertions::assert_eq; use sentry::protocol::Envelope; use sentry::protocol::EnvelopeItem; diff --git a/codex-rs/feedback/src/rollout_attachment_tests.rs b/codex-rs/feedback/src/rollout_attachment_tests.rs index c4163b8ad0..52ac62c771 100644 --- a/codex-rs/feedback/src/rollout_attachment_tests.rs +++ b/codex-rs/feedback/src/rollout_attachment_tests.rs @@ -1,6 +1,6 @@ //! Exercises lazy feedback attachment reads across rollout representations. -use super::*; +use crate::*; use pretty_assertions::assert_eq; const JSONL: &[u8] = b"{\"message\":\"old diagnostic\"}\n{\"message\":\"more details\"}\n"; diff --git a/codex-rs/feedback/src/save_rollout_tests.rs b/codex-rs/feedback/src/save_rollout_tests.rs index 730c2a4637..2036490d48 100644 --- a/codex-rs/feedback/src/save_rollout_tests.rs +++ b/codex-rs/feedback/src/save_rollout_tests.rs @@ -1,5 +1,5 @@ //! Regression coverage for complete, bounded file selection during local saving. -use super::*; +use crate::*; use pretty_assertions::assert_eq; fn rollout_name() -> String { diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index fdb94a993d..fd92dbd88c 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -37,10 +37,6 @@ use nucleo::pattern::Pattern; mod cli; mod snapshot; -#[cfg(test)] -#[path = "matcher_tests.rs"] -mod matcher_tests; - pub use cli::Cli; /// A single match result returned from the search. @@ -644,569 +640,5 @@ impl RunReporter { } #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used)] - - use super::*; - use pretty_assertions::assert_eq; - use std::fs; - use std::sync::Arc; - use std::sync::Condvar; - use std::sync::Mutex; - use std::sync::atomic::AtomicBool; - use std::thread; - use std::time::Duration; - use std::time::Instant; - use tempfile::TempDir; - - #[test] - fn verify_score_is_none_for_non_match() { - let mut utf32buf = Vec::::new(); - let line = "hello"; - let mut matcher = Matcher::new(Config::DEFAULT); - let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf); - let pattern = create_pattern("zzz"); - let score = pattern.score(haystack, &mut matcher); - assert_eq!(score, None); - } - - #[test] - fn tie_breakers_sort_by_path_when_scores_equal() { - let mut matches = vec![ - (100, "b_path".to_string()), - (100, "a_path".to_string()), - (90, "zzz".to_string()), - ]; - - sort_matches(&mut matches); - - // Highest score first; ties broken alphabetically. - let expected = vec![ - (100, "a_path".to_string()), - (100, "b_path".to_string()), - (90, "zzz".to_string()), - ]; - - assert_eq!(matches, expected); - } - - #[test] - fn file_name_from_path_uses_basename() { - assert_eq!(file_name_from_path("foo/bar.txt"), "bar.txt"); - } - - #[test] - fn file_name_from_path_falls_back_to_full_path() { - assert_eq!(file_name_from_path(""), ""); - } - - #[derive(Default)] - pub(super) struct RecordingReporter { - updates: Mutex>, - complete_times: Mutex>, - complete_cv: Condvar, - update_cv: Condvar, - } - - impl RecordingReporter { - fn wait_until( - &self, - mutex: &Mutex, - cv: &Condvar, - timeout: Duration, - mut predicate: F, - ) -> bool - where - F: FnMut(&T) -> bool, - { - let deadline = Instant::now() + timeout; - let mut state = mutex.lock().unwrap(); - loop { - if predicate(&state) { - return true; - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return false; - } - let (next_state, wait_result) = cv.wait_timeout(state, remaining).unwrap(); - state = next_state; - if wait_result.timed_out() { - return predicate(&state); - } - } - } - - fn wait_for_complete(&self, timeout: Duration) -> bool { - self.wait_until( - &self.complete_times, - &self.complete_cv, - timeout, - |completes| !completes.is_empty(), - ) - } - fn clear(&self) { - self.updates.lock().unwrap().clear(); - self.complete_times.lock().unwrap().clear(); - } - - fn updates(&self) -> Vec { - self.updates.lock().unwrap().clone() - } - - fn wait_for_updates_at_least(&self, min_len: usize, timeout: Duration) -> bool { - self.wait_until(&self.updates, &self.update_cv, timeout, |updates| { - updates.len() >= min_len - }) - } - - fn snapshot(&self) -> FileSearchSnapshot { - self.updates - .lock() - .unwrap() - .last() - .cloned() - .unwrap_or_default() - } - } - - impl SessionReporter for RecordingReporter { - fn on_update(&self, snapshot: &FileSearchSnapshot) { - let mut updates = self.updates.lock().unwrap(); - updates.push(snapshot.clone()); - self.update_cv.notify_all(); - } - - fn on_complete(&self) { - { - let mut complete_times = self.complete_times.lock().unwrap(); - complete_times.push(Instant::now()); - } - self.complete_cv.notify_all(); - } - } - - fn create_temp_tree(file_count: usize) -> TempDir { - let dir = tempfile::tempdir().unwrap(); - for i in 0..file_count { - let path = dir.path().join(format!("file-{i:04}.txt")); - fs::write(path, format!("contents {i}")).unwrap(); - } - dir - } - - #[test] - fn session_scanned_file_count_is_monotonic_across_queries() { - let dir = create_temp_tree(/*file_count*/ 200); - let reporter = Arc::new(RecordingReporter::default()); - let session = create_session( - vec![dir.path().to_path_buf()], - FileSearchOptions::default(), - reporter.clone(), - /*cancel_flag*/ None, - ) - .expect("session"); - - session.update_query("file-00"); - thread::sleep(Duration::from_millis(20)); - let first_snapshot = reporter.snapshot(); - session.update_query("file-01"); - thread::sleep(Duration::from_millis(20)); - let second_snapshot = reporter.snapshot(); - let _ = reporter.wait_for_complete(Duration::from_secs(5)); - let completed_snapshot = reporter.snapshot(); - - assert!(second_snapshot.scanned_file_count >= first_snapshot.scanned_file_count); - assert!(completed_snapshot.scanned_file_count >= second_snapshot.scanned_file_count); - } - - #[test] - fn session_streams_updates_before_walk_complete() { - let dir = create_temp_tree(/*file_count*/ 600); - let reporter = Arc::new(RecordingReporter::default()); - let session = create_session( - vec![dir.path().to_path_buf()], - FileSearchOptions::default(), - reporter.clone(), - /*cancel_flag*/ None, - ) - .expect("session"); - - session.update_query("file-0"); - let completed = reporter.wait_for_complete(Duration::from_secs(5)); - - assert!(completed); - let updates = reporter.updates(); - assert!(updates.iter().any(|snapshot| !snapshot.walk_complete)); - } - - #[test] - fn session_accepts_query_updates_after_walk_complete() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("alpha.txt"), "alpha").unwrap(); - fs::write(dir.path().join("beta.txt"), "beta").unwrap(); - let reporter = Arc::new(RecordingReporter::default()); - let session = create_session( - vec![dir.path().to_path_buf()], - FileSearchOptions::default(), - reporter.clone(), - /*cancel_flag*/ None, - ) - .expect("session"); - - session.update_query("alpha"); - assert!(reporter.wait_for_complete(Duration::from_secs(5))); - let updates_before = reporter.updates().len(); - - session.update_query("beta"); - assert!(reporter.wait_for_updates_at_least(updates_before + 1, Duration::from_secs(5),)); - - let updates = reporter.updates(); - let last_update = updates.last().cloned().expect("update"); - assert!( - last_update - .matches - .iter() - .any(|file_match| file_match.path.to_string_lossy().contains("beta.txt")) - ); - } - - #[test] - fn session_emits_complete_when_query_changes_with_no_matches() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("alpha.txt"), "alpha").unwrap(); - fs::write(dir.path().join("beta.txt"), "beta").unwrap(); - let reporter = Arc::new(RecordingReporter::default()); - let session = create_session( - vec![dir.path().to_path_buf()], - FileSearchOptions::default(), - reporter.clone(), - /*cancel_flag*/ None, - ) - .expect("session"); - - session.update_query("asdf"); - assert!(reporter.wait_for_complete(Duration::from_secs(5))); - - let completed_snapshot = reporter.snapshot(); - assert_eq!(completed_snapshot.matches, Vec::new()); - assert_eq!(completed_snapshot.total_match_count, 0); - - reporter.clear(); - - session.update_query("asdfa"); - assert!(reporter.wait_for_complete(Duration::from_secs(5))); - assert!(!reporter.updates().is_empty()); - } - - #[test] - fn dropping_session_does_not_cancel_siblings_with_shared_cancel_flag() { - let root_a = create_temp_tree(/*file_count*/ 200); - let root_b = create_temp_tree(/*file_count*/ 4_000); - let cancel_flag = Arc::new(AtomicBool::new(false)); - - let reporter_a = Arc::new(RecordingReporter::default()); - let session_a = create_session( - vec![root_a.path().to_path_buf()], - FileSearchOptions::default(), - reporter_a, - Some(cancel_flag.clone()), - ) - .expect("session_a"); - - let reporter_b = Arc::new(RecordingReporter::default()); - let session_b = create_session( - vec![root_b.path().to_path_buf()], - FileSearchOptions::default(), - reporter_b.clone(), - Some(cancel_flag), - ) - .expect("session_b"); - - session_a.update_query("file-0"); - session_b.update_query("file-1"); - - thread::sleep(Duration::from_millis(5)); - drop(session_a); - - let completed = reporter_b.wait_for_complete(Duration::from_secs(5)); - assert_eq!(completed, true); - } - - #[test] - fn session_emits_updates_when_query_changes() { - let dir = create_temp_tree(/*file_count*/ 200); - let reporter = Arc::new(RecordingReporter::default()); - let session = create_session( - vec![dir.path().to_path_buf()], - FileSearchOptions::default(), - reporter.clone(), - /*cancel_flag*/ None, - ) - .expect("session"); - - session.update_query("zzzzzzzz"); - let completed = reporter.wait_for_complete(Duration::from_secs(5)); - assert!(completed); - - reporter.clear(); - - session.update_query("zzzzzzzzq"); - let completed = reporter.wait_for_complete(Duration::from_secs(5)); - assert!(completed); - - let updates = reporter.updates(); - assert_eq!(updates.len(), 1); - } - - #[test] - fn run_returns_matches_for_query() { - let dir = create_temp_tree(/*file_count*/ 40); - let options = FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }; - let results = run( - "file-000", - vec![dir.path().to_path_buf()], - options, - /*cancel_flag*/ None, - ) - .expect("run ok"); - - assert!(!results.matches.is_empty()); - assert!(results.total_match_count >= results.matches.len()); - assert!( - results - .matches - .iter() - .any(|m| m.path.to_string_lossy().contains("file-0000.txt")) - ); - } - - #[test] - fn run_returns_directory_matches_for_query() { - let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join("docs/guides")).unwrap(); - fs::write(dir.path().join("docs/guides/intro.md"), "intro").unwrap(); - fs::write(dir.path().join("docs/readme.md"), "readme").unwrap(); - - let results = run( - "guides", - vec![dir.path().to_path_buf()], - FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }, - /*cancel_flag*/ None, - ) - .expect("run ok"); - - assert!(results.matches.iter().any(|m| { - m.path == std::path::Path::new("docs").join("guides") - && m.match_type == MatchType::Directory - })); - } - - #[cfg(unix)] - #[test] - fn run_classifies_followed_directory_symlink_as_directory() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - fs::create_dir(dir.path().join("guides")).unwrap(); - symlink(dir.path().join("guides"), dir.path().join("guides-link")).unwrap(); - - let results = run( - "guides-link", - vec![dir.path().to_path_buf()], - FileSearchOptions::default(), - /*cancel_flag*/ None, - ) - .expect("run ok"); - - assert!(results.matches.iter().any(|file_match| { - file_match.path == Path::new("guides-link") - && file_match.match_type == MatchType::Directory - })); - } - - #[test] - fn cancel_exits_run() { - let dir = create_temp_tree(/*file_count*/ 200); - let cancel_flag = Arc::new(AtomicBool::new(true)); - let search_dir = dir.path().to_path_buf(); - let options = FileSearchOptions { - compute_indices: false, - ..Default::default() - }; - let (tx, rx) = std::sync::mpsc::channel(); - - let handle = thread::spawn(move || { - let result = run("file-", vec![search_dir], options, Some(cancel_flag)); - let _ = tx.send(result); - }); - - let result = rx - .recv_timeout(Duration::from_secs(2)) - .expect("run should exit after cancellation"); - handle.join().unwrap(); - - let results = result.expect("run ok"); - assert_eq!(results.matches, Vec::new()); - assert_eq!(results.total_match_count, 0); - } - - /// Regression test for #3493: a parent directory's `.gitignore` with `*` - /// must not suppress files discovered inside a child "repo" directory. - /// - /// The fixture intentionally omits `git init` so that no `.git` directory - /// exists. With `require_git(true)`, the walker skips all gitignore - /// processing, making the parent's broad ignore harmless. - #[test] - fn parent_gitignore_outside_repo_does_not_hide_repo_files() { - let temp = tempfile::tempdir().unwrap(); - let parent = temp.path().join("home"); - let repo = parent.join("repo"); - fs::create_dir_all(repo.join(".vscode")).unwrap(); - - fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap(); - fs::write( - repo.join(".gitignore"), - ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n", - ) - .unwrap(); - fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap(); - fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap(); - - let respect_results = run( - "package", - vec![repo.clone()], - FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }, - /*cancel_flag*/ None, - ) - .expect("run ok"); - assert!( - respect_results - .matches - .iter() - .any(|m| m.path.as_path() == Path::new("package.json")) - ); - - let nested_file_results = run( - "settings", - vec![repo], - FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }, - /*cancel_flag*/ None, - ) - .expect("run ok"); - assert!( - nested_file_results - .matches - .iter() - .any(|m| m.path.as_path() == Path::new(".vscode/settings.json")) - ); - } - - #[test] - fn git_repo_still_respects_local_gitignore_when_enabled() { - let temp = tempfile::tempdir().unwrap(); - let parent = temp.path().join("home"); - let repo = parent.join("repo"); - fs::create_dir_all(repo.join(".vscode")).unwrap(); - - fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap(); - fs::write( - repo.join(".gitignore"), - ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n", - ) - .unwrap(); - fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap(); - fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap(); - fs::write( - repo.join(".vscode/extensions.json"), - "{ \"extensions\": [] }\n", - ) - .unwrap(); - - fs::create_dir_all(repo.join(".git")).unwrap(); - - let package_results = run( - "package", - vec![repo.clone()], - FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }, - /*cancel_flag*/ None, - ) - .expect("run ok"); - assert!( - package_results - .matches - .iter() - .any(|m| m.path.as_path() == Path::new("package.json")) - ); - - let ignored_results = run( - "extensions.json", - vec![repo.clone()], - FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }, - /*cancel_flag*/ None, - ) - .expect("run ok"); - assert!( - !ignored_results - .matches - .iter() - .any(|m| m.path.as_path() == Path::new(".vscode/extensions.json")) - ); - - let whitelisted_results = run( - "settings.json", - vec![repo], - FileSearchOptions { - limit: NonZero::new(20).unwrap(), - exclude: Vec::new(), - threads: NonZero::new(2).unwrap(), - compute_indices: false, - respect_gitignore: true, - }, - /*cancel_flag*/ None, - ) - .expect("run ok"); - assert!( - whitelisted_results - .matches - .iter() - .any(|m| m.path.as_path() == Path::new(".vscode/settings.json")) - ); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/file-search/src/lib_tests.rs b/codex-rs/file-search/src/lib_tests.rs new file mode 100644 index 0000000000..c2202b63ae --- /dev/null +++ b/codex-rs/file-search/src/lib_tests.rs @@ -0,0 +1,570 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +#![allow(clippy::unwrap_used)] + +use super::*; +use pretty_assertions::assert_eq; +use std::fs; +use std::sync::Arc; +use std::sync::Condvar; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::thread; +use std::time::Duration; +use std::time::Instant; +use tempfile::TempDir; + +#[test] +fn verify_score_is_none_for_non_match() { + let mut utf32buf = Vec::::new(); + let line = "hello"; + let mut matcher = Matcher::new(Config::DEFAULT); + let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf); + let pattern = create_pattern("zzz"); + let score = pattern.score(haystack, &mut matcher); + assert_eq!(score, None); +} + +#[test] +fn tie_breakers_sort_by_path_when_scores_equal() { + let mut matches = vec![ + (100, "b_path".to_string()), + (100, "a_path".to_string()), + (90, "zzz".to_string()), + ]; + + sort_matches(&mut matches); + + // Highest score first; ties broken alphabetically. + let expected = vec![ + (100, "a_path".to_string()), + (100, "b_path".to_string()), + (90, "zzz".to_string()), + ]; + + assert_eq!(matches, expected); +} + +#[test] +fn file_name_from_path_uses_basename() { + assert_eq!(file_name_from_path("foo/bar.txt"), "bar.txt"); +} + +#[test] +fn file_name_from_path_falls_back_to_full_path() { + assert_eq!(file_name_from_path(""), ""); +} + +#[derive(Default)] +pub(super) struct RecordingReporter { + updates: Mutex>, + complete_times: Mutex>, + complete_cv: Condvar, + update_cv: Condvar, +} + +impl RecordingReporter { + fn wait_until( + &self, + mutex: &Mutex, + cv: &Condvar, + timeout: Duration, + mut predicate: F, + ) -> bool + where + F: FnMut(&T) -> bool, + { + let deadline = Instant::now() + timeout; + let mut state = mutex.lock().unwrap(); + loop { + if predicate(&state) { + return true; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (next_state, wait_result) = cv.wait_timeout(state, remaining).unwrap(); + state = next_state; + if wait_result.timed_out() { + return predicate(&state); + } + } + } + + fn wait_for_complete(&self, timeout: Duration) -> bool { + self.wait_until( + &self.complete_times, + &self.complete_cv, + timeout, + |completes| !completes.is_empty(), + ) + } + fn clear(&self) { + self.updates.lock().unwrap().clear(); + self.complete_times.lock().unwrap().clear(); + } + + fn updates(&self) -> Vec { + self.updates.lock().unwrap().clone() + } + + fn wait_for_updates_at_least(&self, min_len: usize, timeout: Duration) -> bool { + self.wait_until(&self.updates, &self.update_cv, timeout, |updates| { + updates.len() >= min_len + }) + } + + fn snapshot(&self) -> FileSearchSnapshot { + self.updates + .lock() + .unwrap() + .last() + .cloned() + .unwrap_or_default() + } +} + +impl SessionReporter for RecordingReporter { + fn on_update(&self, snapshot: &FileSearchSnapshot) { + let mut updates = self.updates.lock().unwrap(); + updates.push(snapshot.clone()); + self.update_cv.notify_all(); + } + + fn on_complete(&self) { + { + let mut complete_times = self.complete_times.lock().unwrap(); + complete_times.push(Instant::now()); + } + self.complete_cv.notify_all(); + } +} + +fn create_temp_tree(file_count: usize) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + for i in 0..file_count { + let path = dir.path().join(format!("file-{i:04}.txt")); + fs::write(path, format!("contents {i}")).unwrap(); + } + dir +} + +#[test] +fn session_scanned_file_count_is_monotonic_across_queries() { + let dir = create_temp_tree(/*file_count*/ 200); + let reporter = Arc::new(RecordingReporter::default()); + let session = create_session( + vec![dir.path().to_path_buf()], + FileSearchOptions::default(), + reporter.clone(), + /*cancel_flag*/ None, + ) + .expect("session"); + + session.update_query("file-00"); + thread::sleep(Duration::from_millis(20)); + let first_snapshot = reporter.snapshot(); + session.update_query("file-01"); + thread::sleep(Duration::from_millis(20)); + let second_snapshot = reporter.snapshot(); + let _ = reporter.wait_for_complete(Duration::from_secs(5)); + let completed_snapshot = reporter.snapshot(); + + assert!(second_snapshot.scanned_file_count >= first_snapshot.scanned_file_count); + assert!(completed_snapshot.scanned_file_count >= second_snapshot.scanned_file_count); +} + +#[test] +fn session_streams_updates_before_walk_complete() { + let dir = create_temp_tree(/*file_count*/ 600); + let reporter = Arc::new(RecordingReporter::default()); + let session = create_session( + vec![dir.path().to_path_buf()], + FileSearchOptions::default(), + reporter.clone(), + /*cancel_flag*/ None, + ) + .expect("session"); + + session.update_query("file-0"); + let completed = reporter.wait_for_complete(Duration::from_secs(5)); + + assert!(completed); + let updates = reporter.updates(); + assert!(updates.iter().any(|snapshot| !snapshot.walk_complete)); +} + +#[test] +fn session_accepts_query_updates_after_walk_complete() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("alpha.txt"), "alpha").unwrap(); + fs::write(dir.path().join("beta.txt"), "beta").unwrap(); + let reporter = Arc::new(RecordingReporter::default()); + let session = create_session( + vec![dir.path().to_path_buf()], + FileSearchOptions::default(), + reporter.clone(), + /*cancel_flag*/ None, + ) + .expect("session"); + + session.update_query("alpha"); + assert!(reporter.wait_for_complete(Duration::from_secs(5))); + let updates_before = reporter.updates().len(); + + session.update_query("beta"); + assert!(reporter.wait_for_updates_at_least(updates_before + 1, Duration::from_secs(5),)); + + let updates = reporter.updates(); + let last_update = updates.last().cloned().expect("update"); + assert!( + last_update + .matches + .iter() + .any(|file_match| file_match.path.to_string_lossy().contains("beta.txt")) + ); +} + +#[test] +fn session_emits_complete_when_query_changes_with_no_matches() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("alpha.txt"), "alpha").unwrap(); + fs::write(dir.path().join("beta.txt"), "beta").unwrap(); + let reporter = Arc::new(RecordingReporter::default()); + let session = create_session( + vec![dir.path().to_path_buf()], + FileSearchOptions::default(), + reporter.clone(), + /*cancel_flag*/ None, + ) + .expect("session"); + + session.update_query("asdf"); + assert!(reporter.wait_for_complete(Duration::from_secs(5))); + + let completed_snapshot = reporter.snapshot(); + assert_eq!(completed_snapshot.matches, Vec::new()); + assert_eq!(completed_snapshot.total_match_count, 0); + + reporter.clear(); + + session.update_query("asdfa"); + assert!(reporter.wait_for_complete(Duration::from_secs(5))); + assert!(!reporter.updates().is_empty()); +} + +#[test] +fn dropping_session_does_not_cancel_siblings_with_shared_cancel_flag() { + let root_a = create_temp_tree(/*file_count*/ 200); + let root_b = create_temp_tree(/*file_count*/ 4_000); + let cancel_flag = Arc::new(AtomicBool::new(false)); + + let reporter_a = Arc::new(RecordingReporter::default()); + let session_a = create_session( + vec![root_a.path().to_path_buf()], + FileSearchOptions::default(), + reporter_a, + Some(cancel_flag.clone()), + ) + .expect("session_a"); + + let reporter_b = Arc::new(RecordingReporter::default()); + let session_b = create_session( + vec![root_b.path().to_path_buf()], + FileSearchOptions::default(), + reporter_b.clone(), + Some(cancel_flag), + ) + .expect("session_b"); + + session_a.update_query("file-0"); + session_b.update_query("file-1"); + + thread::sleep(Duration::from_millis(5)); + drop(session_a); + + let completed = reporter_b.wait_for_complete(Duration::from_secs(5)); + assert_eq!(completed, true); +} + +#[test] +fn session_emits_updates_when_query_changes() { + let dir = create_temp_tree(/*file_count*/ 200); + let reporter = Arc::new(RecordingReporter::default()); + let session = create_session( + vec![dir.path().to_path_buf()], + FileSearchOptions::default(), + reporter.clone(), + /*cancel_flag*/ None, + ) + .expect("session"); + + session.update_query("zzzzzzzz"); + let completed = reporter.wait_for_complete(Duration::from_secs(5)); + assert!(completed); + + reporter.clear(); + + session.update_query("zzzzzzzzq"); + let completed = reporter.wait_for_complete(Duration::from_secs(5)); + assert!(completed); + + let updates = reporter.updates(); + assert_eq!(updates.len(), 1); +} + +#[test] +fn run_returns_matches_for_query() { + let dir = create_temp_tree(/*file_count*/ 40); + let options = FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }; + let results = run( + "file-000", + vec![dir.path().to_path_buf()], + options, + /*cancel_flag*/ None, + ) + .expect("run ok"); + + assert!(!results.matches.is_empty()); + assert!(results.total_match_count >= results.matches.len()); + assert!( + results + .matches + .iter() + .any(|m| m.path.to_string_lossy().contains("file-0000.txt")) + ); +} + +#[test] +fn run_returns_directory_matches_for_query() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("docs/guides")).unwrap(); + fs::write(dir.path().join("docs/guides/intro.md"), "intro").unwrap(); + fs::write(dir.path().join("docs/readme.md"), "readme").unwrap(); + + let results = run( + "guides", + vec![dir.path().to_path_buf()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + /*cancel_flag*/ None, + ) + .expect("run ok"); + + assert!(results.matches.iter().any(|m| { + m.path == std::path::Path::new("docs").join("guides") + && m.match_type == MatchType::Directory + })); +} + +#[cfg(unix)] +#[test] +fn run_classifies_followed_directory_symlink_as_directory() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("guides")).unwrap(); + symlink(dir.path().join("guides"), dir.path().join("guides-link")).unwrap(); + + let results = run( + "guides-link", + vec![dir.path().to_path_buf()], + FileSearchOptions::default(), + /*cancel_flag*/ None, + ) + .expect("run ok"); + + assert!(results.matches.iter().any(|file_match| { + file_match.path == Path::new("guides-link") && file_match.match_type == MatchType::Directory + })); +} + +#[test] +fn cancel_exits_run() { + let dir = create_temp_tree(/*file_count*/ 200); + let cancel_flag = Arc::new(AtomicBool::new(true)); + let search_dir = dir.path().to_path_buf(); + let options = FileSearchOptions { + compute_indices: false, + ..Default::default() + }; + let (tx, rx) = std::sync::mpsc::channel(); + + let handle = thread::spawn(move || { + let result = run("file-", vec![search_dir], options, Some(cancel_flag)); + let _ = tx.send(result); + }); + + let result = rx + .recv_timeout(Duration::from_secs(2)) + .expect("run should exit after cancellation"); + handle.join().unwrap(); + + let results = result.expect("run ok"); + assert_eq!(results.matches, Vec::new()); + assert_eq!(results.total_match_count, 0); +} + +/// Regression test for #3493: a parent directory's `.gitignore` with `*` +/// must not suppress files discovered inside a child "repo" directory. +/// +/// The fixture intentionally omits `git init` so that no `.git` directory +/// exists. With `require_git(true)`, the walker skips all gitignore +/// processing, making the parent's broad ignore harmless. +#[test] +fn parent_gitignore_outside_repo_does_not_hide_repo_files() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("home"); + let repo = parent.join("repo"); + fs::create_dir_all(repo.join(".vscode")).unwrap(); + + fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap(); + fs::write( + repo.join(".gitignore"), + ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n", + ) + .unwrap(); + fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap(); + fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap(); + + let respect_results = run( + "package", + vec![repo.clone()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + /*cancel_flag*/ None, + ) + .expect("run ok"); + assert!( + respect_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new("package.json")) + ); + + let nested_file_results = run( + "settings", + vec![repo], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + /*cancel_flag*/ None, + ) + .expect("run ok"); + assert!( + nested_file_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new(".vscode/settings.json")) + ); +} + +#[test] +fn git_repo_still_respects_local_gitignore_when_enabled() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("home"); + let repo = parent.join("repo"); + fs::create_dir_all(repo.join(".vscode")).unwrap(); + + fs::write(parent.join(".gitignore"), "*\n!.gitignore\n").unwrap(); + fs::write( + repo.join(".gitignore"), + ".vscode/*\n!.vscode/\n!.vscode/settings.json\n!package.json\n", + ) + .unwrap(); + fs::write(repo.join("package.json"), "{ \"name\": \"demo\" }\n").unwrap(); + fs::write(repo.join(".vscode/settings.json"), "{ \"editor\": true }\n").unwrap(); + fs::write( + repo.join(".vscode/extensions.json"), + "{ \"extensions\": [] }\n", + ) + .unwrap(); + + fs::create_dir_all(repo.join(".git")).unwrap(); + + let package_results = run( + "package", + vec![repo.clone()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + /*cancel_flag*/ None, + ) + .expect("run ok"); + assert!( + package_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new("package.json")) + ); + + let ignored_results = run( + "extensions.json", + vec![repo.clone()], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + /*cancel_flag*/ None, + ) + .expect("run ok"); + assert!( + !ignored_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new(".vscode/extensions.json")) + ); + + let whitelisted_results = run( + "settings.json", + vec![repo], + FileSearchOptions { + limit: NonZero::new(20).unwrap(), + exclude: Vec::new(), + threads: NonZero::new(2).unwrap(), + compute_indices: false, + respect_gitignore: true, + }, + /*cancel_flag*/ None, + ) + .expect("run ok"); + assert!( + whitelisted_results + .matches + .iter() + .any(|m| m.path.as_path() == Path::new(".vscode/settings.json")) + ); +} + +#[path = "matcher_tests.rs"] +mod matcher_tests; diff --git a/codex-rs/file-search/src/matcher_tests.rs b/codex-rs/file-search/src/matcher_tests.rs index 3dea8261af..551cc1858b 100644 --- a/codex-rs/file-search/src/matcher_tests.rs +++ b/codex-rs/file-search/src/matcher_tests.rs @@ -1,6 +1,6 @@ //! Regression coverage for associating asynchronous matcher results with their query. -use super::*; +use crate::*; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/install-context/src/bundle_tests.rs b/codex-rs/install-context/src/bundle_tests.rs index cf49aa69b0..6208a9a163 100644 --- a/codex-rs/install-context/src/bundle_tests.rs +++ b/codex-rs/install-context/src/bundle_tests.rs @@ -1,6 +1,6 @@ //! Ensure a provisioned CLI still discovers its outer package and install method. -use super::*; +use crate::*; use pretty_assertions::assert_eq; use std::fs; diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 899211bcb2..d89cb04da0 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -359,522 +359,5 @@ fn default_rg_command() -> PathBuf { } #[cfg(test)] -#[path = "bundle_tests.rs"] -mod bundle_tests; - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::fs; - - const TEST_RESOURCE_NAME: &str = "codex-test-helper"; - - #[test] - fn code_mode_host_program_prefers_package_resource_over_legacy_binary() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(&resources_dir)?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); - fs::write(&exe_path, "")?; - fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "legacy host")?; - fs::write(&resource_host, "managed host")?; - - let context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - ); - - assert_eq!( - context.code_mode_host_program(), - AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf() - ); - Ok(()) - } - - #[test] - fn code_mode_host_program_uses_package_resource_without_legacy_binary() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(&resources_dir)?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); - fs::write(&exe_path, "")?; - fs::write(&resource_host, "managed host")?; - - let context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - ); - - assert_eq!( - context.code_mode_host_program(), - AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf() - ); - Ok(()) - } - - #[test] - fn code_mode_host_program_ignores_resource_directory_and_uses_legacy_binary() - -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - let legacy_host = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); - fs::write(&exe_path, "")?; - fs::write(&legacy_host, "legacy host")?; - - let context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - ); - - assert_eq!( - context.code_mode_host_program(), - AbsolutePathBuf::from_absolute_path(legacy_host.canonicalize()?)?.into_path_buf() - ); - Ok(()) - } - - #[test] - fn detects_standalone_install_from_release_layout() -> std::io::Result<()> { - let codex_home = tempfile::tempdir()?; - let release_dir = codex_home - .path() - .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); - let resources_dir = release_dir.join(RESOURCES_DIRNAME); - fs::create_dir_all(&resources_dir)?; - let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - fs::write(release_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; - fs::write( - resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), - "managed host", - )?; - fs::write(resources_dir.join(default_rg_command()), "")?; - fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; - let canonical_release_dir = - AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?; - let canonical_resources_dir = - AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; - - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - /*codex_home*/ Some(codex_home.path()), - ); - assert_eq!( - context, - InstallContext { - method: InstallMethod::Standalone { - release_dir: canonical_release_dir.clone(), - resources_dir: Some(canonical_resources_dir.clone()), - platform: standalone_platform(), - }, - package_layout: None, - } - ); - assert_eq!( - context.code_mode_host_program_from_exe(Some(&exe_path)), - canonical_release_dir - .join(CODE_MODE_HOST_EXECUTABLE_NAME) - .into_path_buf() - ); - assert_eq!( - context.code_mode_host_program(), - canonical_resources_dir - .join(CODE_MODE_HOST_EXECUTABLE_NAME) - .into_path_buf() - ); - assert_eq!( - context.bundled_resource(TEST_RESOURCE_NAME), - Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) - ); - Ok(()) - } - - #[test] - fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> { - let codex_home = tempfile::tempdir()?; - let release_dir = codex_home - .path() - .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); - fs::create_dir_all(&release_dir)?; - let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - /*codex_home*/ Some(codex_home.path()), - ); - assert_eq!(context.rg_command(), default_rg_command()); - Ok(()) - } - - #[test] - fn detects_package_layout_independently_from_install_method() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); - let path_dir = package_dir.path().join(PATH_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(&resources_dir)?; - fs::create_dir_all(&path_dir)?; - fs::write( - package_dir.path().join(PACKAGE_METADATA_FILENAME), - r#"{ - "layoutVersion": 1, - "version": "1.2.3", - "target": "x86_64-unknown-linux-musl", - "variant": "codex", - "entrypoint": "bin/codex", - "resourcesDir": "codex-resources", - "pathDir": "codex-path" -} -"#, - )?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; - fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; - fs::write(path_dir.join(default_rg_command()), "")?; - let canonical_package_dir = - AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; - let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; - let canonical_resources_dir = - AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; - let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; - let package_layout = CodexPackageLayout { - package_dir: canonical_package_dir, - bin_dir: canonical_bin_dir.clone(), - resources_dir: Some(canonical_resources_dir.clone()), - path_dir: Some(canonical_path_dir.clone()), - }; - - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - /*codex_home*/ None, - ); - assert_eq!( - context, - InstallContext { - method: InstallMethod::Other, - package_layout: Some(package_layout), - } - ); - assert_eq!( - context.package_manifest(), - Some(CodexPackageManifest { - version: Version::new(1, 2, 3), - }) - ); - assert_eq!( - context.code_mode_host_program_from_exe(Some(&exe_path)), - canonical_bin_dir - .join(CODE_MODE_HOST_EXECUTABLE_NAME) - .into_path_buf() - ); - assert_eq!( - context.code_mode_host_program(), - canonical_bin_dir - .join(CODE_MODE_HOST_EXECUTABLE_NAME) - .into_path_buf() - ); - assert_eq!( - context.rg_command(), - canonical_path_dir - .join(default_rg_command()) - .into_path_buf() - ); - assert_eq!( - context.bundled_resource(TEST_RESOURCE_NAME), - Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) - ); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn code_mode_host_program_accepts_symlinks_to_files() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join("codex"); - let executable_target = package_dir.path().join("host-target"); - let executable_path = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); - fs::write(&exe_path, "")?; - fs::write(&executable_target, "")?; - std::os::unix::fs::symlink(&executable_target, &executable_path)?; - let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; - - let context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - ); - - assert_eq!( - context.code_mode_host_program_from_exe(Some(&exe_path)), - canonical_bin_dir - .join(CODE_MODE_HOST_EXECUTABLE_NAME) - .into_path_buf() - ); - Ok(()) - } - - #[test] - fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> { - let codex_home = tempfile::tempdir()?; - let package_dir = codex_home - .path() - .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); - let bin_dir = package_dir.join(BIN_DIRNAME); - let resources_dir = package_dir.join(RESOURCES_DIRNAME); - let path_dir = package_dir.join(PATH_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(&resources_dir)?; - fs::create_dir_all(&path_dir)?; - fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; - fs::write(path_dir.join(default_rg_command()), "")?; - let canonical_package_dir = - AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?; - let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; - let canonical_resources_dir = - AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; - let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; - - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - /*codex_home*/ Some(codex_home.path()), - ); - assert_eq!( - context, - InstallContext { - method: InstallMethod::Standalone { - release_dir: canonical_package_dir.clone(), - resources_dir: Some(canonical_resources_dir.clone()), - platform: standalone_platform(), - }, - package_layout: Some(CodexPackageLayout { - package_dir: canonical_package_dir, - bin_dir: canonical_bin_dir, - resources_dir: Some(canonical_resources_dir.clone()), - path_dir: Some(canonical_path_dir.clone()), - }), - } - ); - assert_eq!( - context.rg_command(), - canonical_path_dir - .join(default_rg_command()) - .into_path_buf() - ); - assert_eq!( - context.bundled_resource(TEST_RESOURCE_NAME), - Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) - ); - Ok(()) - } - - #[test] - fn npm_managed_package_keeps_package_layout() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - let path_dir = package_dir.path().join(PATH_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(&path_dir)?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - fs::write(path_dir.join(default_rg_command()), "")?; - let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; - - let context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ Some(InstallMethod::Npm), - ); - assert_eq!(context.method, InstallMethod::Npm); - assert!(context.package_layout.is_some()); - assert_eq!( - context.rg_command(), - canonical_path_dir - .join(default_rg_command()) - .into_path_buf() - ); - Ok(()) - } - - #[test] - fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - /*codex_home*/ None, - ); - assert_eq!(context.rg_command(), default_rg_command()); - Ok(()) - } - - #[test] - fn bundled_file_lookups_ignore_directories() -> std::io::Result<()> { - let package_dir = tempfile::tempdir()?; - let bin_dir = package_dir.path().join(BIN_DIRNAME); - let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); - let path_dir = package_dir.path().join(PATH_DIRNAME); - fs::create_dir_all(&bin_dir)?; - fs::create_dir_all(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; - fs::create_dir_all(resources_dir.join(TEST_RESOURCE_NAME))?; - fs::create_dir_all(path_dir.join(default_rg_command()))?; - fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; - let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - let fallback_exe_path = package_dir.path().join("fallback-codex"); - fs::write(&fallback_exe_path, "")?; - - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ false, - /*current_exe*/ Some(&exe_path), - /*method_override*/ None, - /*codex_home*/ None, - ); - assert_eq!( - context.code_mode_host_program_from_exe(Some(&fallback_exe_path)), - package_dir.path().join(CODE_MODE_HOST_EXECUTABLE_NAME) - ); - assert_eq!(context.rg_command(), default_rg_command()); - assert_eq!(context.bundled_resource(TEST_RESOURCE_NAME), None); - Ok(()) - } - - #[test] - fn code_mode_host_program_is_next_to_the_executable_even_when_missing() { - let context = InstallContext { - method: InstallMethod::Other, - package_layout: None, - }; - - assert_eq!( - context.code_mode_host_program_from_exe(Some(Path::new("/opt/codex/bin/codex"))), - PathBuf::from("/opt/codex/bin").join(CODE_MODE_HOST_EXECUTABLE_NAME) - ); - } - - #[test] - fn code_mode_host_program_falls_back_to_its_name_when_executable_is_unknown() { - let context = InstallContext { - method: InstallMethod::Other, - package_layout: None, - }; - - assert_eq!( - context.code_mode_host_program_from_exe(/*current_exe*/ None), - PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME) - ); - } - - #[test] - fn package_manager_method_overrides_take_precedence() { - let vite_plus_context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(Path::new("/tmp/codex")), - /*method_override*/ Some(InstallMethod::VitePlus), - ); - assert_eq!( - vite_plus_context, - InstallContext { - method: InstallMethod::VitePlus, - package_layout: None, - } - ); - - let pnpm_context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(Path::new("/tmp/codex")), - /*method_override*/ Some(InstallMethod::Pnpm), - ); - assert_eq!( - pnpm_context, - InstallContext { - method: InstallMethod::Pnpm, - package_layout: None, - } - ); - - let npm_context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(Path::new("/tmp/codex")), - /*method_override*/ Some(InstallMethod::Npm), - ); - assert_eq!( - npm_context, - InstallContext { - method: InstallMethod::Npm, - package_layout: None, - } - ); - - let bun_context = InstallContext::from_exe( - /*is_macos*/ false, - /*current_exe*/ Some(Path::new("/tmp/codex")), - /*method_override*/ Some(InstallMethod::Bun), - ); - assert_eq!( - bun_context, - InstallContext { - method: InstallMethod::Bun, - package_layout: None, - } - ); - } - - #[test] - fn brew_is_detected_on_macos_prefixes() { - let context = InstallContext::from_exe_with_codex_home( - /*is_macos*/ true, - /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), - /*method_override*/ None, - /*codex_home*/ None, - ); - assert_eq!( - context, - InstallContext { - method: InstallMethod::Brew, - package_layout: None, - } - ); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/install-context/src/lib_tests.rs b/codex-rs/install-context/src/lib_tests.rs new file mode 100644 index 0000000000..9cfcd5b9c6 --- /dev/null +++ b/codex-rs/install-context/src/lib_tests.rs @@ -0,0 +1,518 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +use super::*; +use pretty_assertions::assert_eq; +use std::fs; + +const TEST_RESOURCE_NAME: &str = "codex-test-helper"; + +#[test] +fn code_mode_host_program_prefers_package_resource_over_legacy_binary() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "legacy host")?; + fs::write(&resource_host, "managed host")?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program(), + AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf() + ); + Ok(()) +} + +#[test] +fn code_mode_host_program_uses_package_resource_without_legacy_binary() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&resource_host, "managed host")?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program(), + AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf() + ); + Ok(()) +} + +#[test] +fn code_mode_host_program_ignores_resource_directory_and_uses_legacy_binary() -> std::io::Result<()> +{ + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + let legacy_host = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&legacy_host, "legacy host")?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program(), + AbsolutePathBuf::from_absolute_path(legacy_host.canonicalize()?)?.into_path_buf() + ); + Ok(()) +} + +#[test] +fn detects_standalone_install_from_release_layout() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(release_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; + fs::write( + resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), + "managed host", + )?; + fs::write(resources_dir.join(default_rg_command()), "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; + let canonical_release_dir = AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_release_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: None, + } + ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_release_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.code_mode_host_program(), + canonical_resources_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); + Ok(()) +} + +#[test] +fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + fs::create_dir_all(&release_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) +} + +#[test] +fn detects_package_layout_independently_from_install_method() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write( + package_dir.path().join(PACKAGE_METADATA_FILENAME), + r#"{ + "layoutVersion": 1, + "version": "1.2.3", + "target": "x86_64-unknown-linux-musl", + "variant": "codex", + "entrypoint": "bin/codex", + "resourcesDir": "codex-resources", + "pathDir": "codex-path" +} +"#, + )?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + let package_layout = CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + path_dir: Some(canonical_path_dir.clone()), + }; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Other, + package_layout: Some(package_layout), + } + ); + assert_eq!( + context.package_manifest(), + Some(CodexPackageManifest { + version: Version::new(1, 2, 3), + }) + ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.code_mode_host_program(), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn code_mode_host_program_accepts_symlinks_to_files() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join("codex"); + let executable_target = package_dir.path().join("host-target"); + let executable_path = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&executable_target, "")?; + std::os::unix::fs::symlink(&executable_target, &executable_path)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + Ok(()) +} + +#[test] +fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let package_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let resources_dir = package_dir.join(RESOURCES_DIRNAME); + let path_dir = package_dir.join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_package_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: Some(CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir.clone()), + path_dir: Some(canonical_path_dir.clone()), + }), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); + Ok(()) +} + +#[test] +fn npm_managed_package_keeps_package_layout() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ Some(InstallMethod::Npm), + ); + assert_eq!(context.method, InstallMethod::Npm); + assert!(context.package_layout.is_some()); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) +} + +#[test] +fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) +} + +#[test] +fn bundled_file_lookups_ignore_directories() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; + fs::create_dir_all(resources_dir.join(TEST_RESOURCE_NAME))?; + fs::create_dir_all(path_dir.join(default_rg_command()))?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + let fallback_exe_path = package_dir.path().join("fallback-codex"); + fs::write(&fallback_exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&fallback_exe_path)), + package_dir.path().join(CODE_MODE_HOST_EXECUTABLE_NAME) + ); + assert_eq!(context.rg_command(), default_rg_command()); + assert_eq!(context.bundled_resource(TEST_RESOURCE_NAME), None); + Ok(()) +} + +#[test] +fn code_mode_host_program_is_next_to_the_executable_even_when_missing() { + let context = InstallContext { + method: InstallMethod::Other, + package_layout: None, + }; + + assert_eq!( + context.code_mode_host_program_from_exe(Some(Path::new("/opt/codex/bin/codex"))), + PathBuf::from("/opt/codex/bin").join(CODE_MODE_HOST_EXECUTABLE_NAME) + ); +} + +#[test] +fn code_mode_host_program_falls_back_to_its_name_when_executable_is_unknown() { + let context = InstallContext { + method: InstallMethod::Other, + package_layout: None, + }; + + assert_eq!( + context.code_mode_host_program_from_exe(/*current_exe*/ None), + PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME) + ); +} + +#[test] +fn package_manager_method_overrides_take_precedence() { + let vite_plus_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::VitePlus), + ); + assert_eq!( + vite_plus_context, + InstallContext { + method: InstallMethod::VitePlus, + package_layout: None, + } + ); + + let pnpm_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Pnpm), + ); + assert_eq!( + pnpm_context, + InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + } + ); + + let npm_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Npm), + ); + assert_eq!( + npm_context, + InstallContext { + method: InstallMethod::Npm, + package_layout: None, + } + ); + + let bun_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Bun), + ); + assert_eq!( + bun_context, + InstallContext { + method: InstallMethod::Bun, + package_layout: None, + } + ); +} + +#[test] +fn brew_is_detected_on_macos_prefixes() { + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ true, + /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Brew, + package_layout: None, + } + ); +} + +#[path = "bundle_tests.rs"] +mod bundle_tests; diff --git a/codex-rs/mermaid/src/families_tests.rs b/codex-rs/mermaid/src/families_tests.rs index 70542d3bc5..a6c0fe8877 100644 --- a/codex-rs/mermaid/src/families_tests.rs +++ b/codex-rs/mermaid/src/families_tests.rs @@ -1,7 +1,7 @@ //! End-to-end grammar, topology, and rendered output checks for each supported family. -use super::RenderError; -use super::render; +use crate::RenderError; +use crate::render; use pretty_assertions::assert_eq; use unicode_width::UnicodeWidthStr; @@ -138,12 +138,12 @@ fn unicode_labels_and_later_declarations() { let mut body = vec!["S --> T"]; body.extend(&descriptions); body.push("T --> S"); - let graph = super::state::parse(&body).unwrap(); - let mut expected = super::state::parse(&["S --> T", "T --> S"]).unwrap(); - expected.nodes[0] = super::Node { + let graph = crate::state::parse(&body).unwrap(); + let mut expected = crate::state::parse(&["S --> T", "T --> S"]).unwrap(); + expected.nodes[0] = crate::Node { id: "S".to_owned(), label: "Ready".to_owned(), - shape: super::Shape::Rectangle, + shape: crate::Shape::Rectangle, declared: true, members: if descriptions.len() == 1 { Vec::new() @@ -229,14 +229,14 @@ fn graph_relationship_endpoints() { ); } let source = "classDiagram\nclass A {\nstring a;b\nObject[] elementData\n}\nA : +get()"; - let statements = super::syntax::statements(source).unwrap(); + let statements = crate::syntax::statements(source).unwrap(); assert_eq!( - super::relations::parse(statements[0], &statements[1..]).unwrap(), - super::Graph { - nodes: vec![super::Node { + crate::relations::parse(statements[0], &statements[1..]).unwrap(), + crate::Graph { + nodes: vec![crate::Node { id: "A".into(), label: "A".into(), - shape: super::Shape::Rectangle, + shape: crate::Shape::Rectangle, declared: false, members: vec![ "string a;b".into(), @@ -244,7 +244,7 @@ fn graph_relationship_endpoints() { "+get()".into() ], }], - ..super::Graph::default() + ..crate::Graph::default() } ); for (line, target, target_tip, target_label, dashed) in [ @@ -253,11 +253,11 @@ fn graph_relationship_endpoints() { ("A --o B", "B", '◇', "", false), (r#"A ..o"many" B"#, "B", '◇', "(many)", true), ] { - let graph = super::relations::parse("classDiagram", &[line]).unwrap(); - let mut expected = super::Graph::default(); + let graph = crate::relations::parse("classDiagram", &[line]).unwrap(); + let mut expected = crate::Graph::default(); let from = expected.node("A").unwrap(); let to = expected.node(target).unwrap(); - expected.edges.push(super::Edge { + expected.edges.push(crate::Edge { from, to, label: String::new(), diff --git a/codex-rs/mermaid/src/lib.rs b/codex-rs/mermaid/src/lib.rs index b62fdfb7da..684b03e69b 100644 --- a/codex-rs/mermaid/src/lib.rs +++ b/codex-rs/mermaid/src/lib.rs @@ -170,7 +170,3 @@ impl Graph { #[cfg(test)] #[path = "tests.rs"] mod tests; - -#[cfg(test)] -#[path = "families_tests.rs"] -mod families_tests; diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__LR.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__LR.snap similarity index 100% rename from codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__LR.snap rename to codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__LR.snap diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__class.snap similarity index 100% rename from codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__class.snap rename to codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__class.snap diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__er.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__er.snap similarity index 100% rename from codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__er.snap rename to codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__er.snap diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__sequence.snap similarity index 100% rename from codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__sequence.snap rename to codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__sequence.snap diff --git a/codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap b/codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__state.snap similarity index 100% rename from codex-rs/mermaid/src/snapshots/codex_mermaid__families_tests__state.snap rename to codex-rs/mermaid/src/snapshots/codex_mermaid__tests__families_tests__state.snap diff --git a/codex-rs/mermaid/src/tests.rs b/codex-rs/mermaid/src/tests.rs index cabc9ae3c8..eaa2ffaacb 100644 --- a/codex-rs/mermaid/src/tests.rs +++ b/codex-rs/mermaid/src/tests.rs @@ -448,3 +448,6 @@ fn semantic_spans_distinguish_labels_from_matching_endpoint_glyphs() { assert_eq!(ports, vec![Role::Node, Role::Node]); } } + +#[path = "families_tests.rs"] +mod families_tests; diff --git a/codex-rs/message-history/src/batch_tests.rs b/codex-rs/message-history/src/batch_tests.rs index c0e4c10d11..6741a027b9 100644 --- a/codex-rs/message-history/src/batch_tests.rs +++ b/codex-rs/message-history/src/batch_tests.rs @@ -5,7 +5,7 @@ use codex_config::types::History; use pretty_assertions::assert_eq; use tempfile::TempDir; -use super::*; +use crate::*; fn entry(offset: usize, text: impl Into) -> HistoryEntry { HistoryEntry { diff --git a/codex-rs/message-history/src/lib.rs b/codex-rs/message-history/src/lib.rs index 4b2ce273b0..1eaece4c6b 100644 --- a/codex-rs/message-history/src/lib.rs +++ b/codex-rs/message-history/src/lib.rs @@ -439,8 +439,5 @@ fn log_identity(_metadata: &std::fs::Metadata) -> Option { None } -#[cfg(test)] -#[path = "batch_tests.rs"] -mod batch_tests; #[cfg(test)] mod tests; diff --git a/codex-rs/message-history/src/tests.rs b/codex-rs/message-history/src/tests.rs index f4c7e66e1c..6c6a59d096 100644 --- a/codex-rs/message-history/src/tests.rs +++ b/codex-rs/message-history/src/tests.rs @@ -218,3 +218,6 @@ async fn append_entry_trims_history_to_soft_cap() { assert_eq!(pruned_len, long_entry_len); assert!(pruned_len <= soft_cap_bytes.max(long_entry_len)); } + +#[path = "batch_tests.rs"] +mod batch_tests; diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 346257f63c..c689eeb509 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -1,7 +1,7 @@ //! Opportunistic attachment may fall back; automatic startup requires a shared server. -use super::*; use crate::legacy_core::config::ConfigBuilder; +use crate::*; use pretty_assertions::assert_eq; use tempfile::TempDir; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 23c3b15e3e..ac7ec3b53d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -272,10 +272,6 @@ mod workspace_messages; mod wrapping; mod table_detect; -#[cfg(test)] -pub(crate) mod test_backend; -#[cfg(test)] -pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_gov_cloud_guidance; @@ -2476,1734 +2472,10 @@ mod daemon_startup; mod daemon_telemetry; #[cfg(test)] -#[path = "daemon_startup_tests.rs"] -mod daemon_startup_tests; +#[path = "lib_tests.rs"] +pub(crate) mod tests; #[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::legacy_core::config::ConfigBuilder; - use crate::legacy_core::config::ConfigOverrides; - use clap::Parser; - use codex_app_server_protocol::AskForApproval; - use codex_app_server_protocol::ClientRequest; - use codex_app_server_protocol::RequestId; - use codex_app_server_protocol::ThreadStartParams; - use codex_app_server_protocol::ThreadStartResponse; - use codex_utils_absolute_path::test_support::PathExt; - use pretty_assertions::assert_eq; - use tempfile::TempDir; - - #[test] - fn tui_startup_future_stays_bounded() { - let future = run_main( - Cli::parse_from(["codex"]), - Arg0DispatchPaths::default(), - LoaderOverrides::default(), - /*explicit_remote_endpoint*/ None, - ); - let size = std::mem::size_of_val(&future); - - assert!(size < 64 * 1024, "TUI startup future is {size} bytes"); - } - - async fn build_config(temp_dir: &TempDir) -> std::io::Result { - ConfigBuilder::default() - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .codex_home(temp_dir.path().to_path_buf()) - .build() - .await - } - - #[tokio::test] - async fn server_account_requirement_controls_login_screen() -> color_eyre::Result<()> { - for requires_openai_auth in [false, true] { - let home = TempDir::new()?; - std::fs::write( - home.path().join("config.toml"), - format!( - r#" -model_provider = "test-provider" -[model_providers.test-provider] -name = "Test provider" -base_url = "http://example.test/v1" -wire_api = "responses" -requires_openai_auth = {requires_openai_auth} -"# - ), - )?; - let server_config = build_config(&home).await?; - let mut server = AppServerSession::new( - crate::backend_id::BackendId::new(), - AppServerClient::InProcess(start_test_embedded_app_server(server_config).await?), - ThreadParamsMode::Embedded, - ); - let (login_status, account) = get_login_status(&mut server).await?; - assert_eq!(account.requires_openai_auth, requires_openai_auth); - assert_eq!( - should_show_login_screen(login_status, account.requires_openai_auth), - requires_openai_auth - ); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::Chatgpt), - account.requires_openai_auth - )); - server.shutdown().await?; - } - Ok(()) - } - - #[tokio::test] - async fn bedrock_setup_wizard_requires_eligible_onboarding() -> color_eyre::Result<()> { - let shared_endpoint = RemoteAppServerEndpoint::WebSocket { - websocket_url: "ws://127.0.0.1:4500/".to_string(), - auth_token: None, - }; - let enabled = "[features]\nbedrock_setup_wizard = true\n"; - - for (label, config_toml, login_status, target, expected) in [ - ( - "enabled by default", - "", - LoginStatus::NotAuthenticated, - AppServerTarget::Embedded, - true, - ), - ( - "explicitly disabled", - "[features]\nbedrock_setup_wizard = false\n", - LoginStatus::NotAuthenticated, - AppServerTarget::Embedded, - false, - ), - ( - "enabled for the default provider", - enabled, - LoginStatus::NotAuthenticated, - AppServerTarget::Embedded, - true, - ), - ( - "explicit provider", - "model_provider = \"openai\"\n[features]\nbedrock_setup_wizard = true\n", - LoginStatus::NotAuthenticated, - AppServerTarget::Embedded, - false, - ), - ( - "forced ChatGPT login", - "forced_login_method = \"chatgpt\"\n[features]\nbedrock_setup_wizard = true\n", - LoginStatus::NotAuthenticated, - AppServerTarget::Embedded, - false, - ), - ( - "existing authentication", - enabled, - LoginStatus::AuthMode(AuthMode::Chatgpt), - AppServerTarget::Embedded, - false, - ), - ( - "shared local daemon", - enabled, - LoginStatus::NotAuthenticated, - AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: shared_endpoint.clone(), - }, - false, - ), - ( - "remote app server", - enabled, - LoginStatus::NotAuthenticated, - AppServerTarget::Remote { - endpoint: shared_endpoint, - }, - false, - ), - ] { - let codex_home = TempDir::new()?; - std::fs::write(codex_home.path().join("config.toml"), config_toml)?; - let config = build_config(&codex_home).await?; - - assert_eq!( - should_show_bedrock_setup_wizard( - login_status, - /*requires_openai_auth*/ true, - &config, - &target - ), - expected, - "{label}" - ); - } - - Ok(()) - } - - pub(crate) fn write_session_rollout( - codex_home: &Path, - filename_ts: &str, - meta_rfc3339: &str, - preview: &str, - model_provider: &str, - cwd: &Path, - ) -> color_eyre::Result { - let uuid = Uuid::new_v4(); - let uuid_str = uuid.to_string(); - let thread_id = ThreadId::from_string(&uuid_str)?; - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let rollout_path = codex_home - .join("sessions") - .join(year) - .join(month) - .join(day) - .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); - let parent = rollout_path - .parent() - .ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?; - std::fs::create_dir_all(parent)?; - - let session_meta = codex_protocol::protocol::SessionMeta { - session_id: thread_id.into(), - id: thread_id, - timestamp: meta_rfc3339.to_string(), - cwd: cwd.to_path_buf(), - originator: "codex".to_string(), - cli_version: "0.0.0".to_string(), - source: codex_protocol::protocol::SessionSource::Cli, - model_provider: Some(model_provider.to_string()), - ..Default::default() - }; - let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { - meta: session_meta, - git: None, - })?; - let lines = [ - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": session_meta, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "response_item", - "payload": { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": preview}], - }, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "event_msg", - "payload": { - "type": "user_message", - "message": preview, - "kind": "plain", - }, - }) - .to_string(), - ]; - std::fs::write(&rollout_path, lines.join("\n") + "\n")?; - let updated_at = - chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); - let times = std::fs::FileTimes::new().set_modified(updated_at.into()); - std::fs::OpenOptions::new() - .append(true) - .open(rollout_path)? - .set_times(times)?; - - Ok(thread_id) - } - - #[test] - fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let legacy_log_dir = temp_dir.path().join("log"); - std::fs::create_dir_all(&legacy_log_dir)?; - let legacy_log = legacy_log_dir.join(TUI_LOG_FILE_NAME); - std::fs::write(&legacy_log, "legacy log")?; - - remove_legacy_tui_log_file(temp_dir.path()); - - assert!(!legacy_log.exists()); - Ok(()) - } - - #[tokio::test] - async fn startup_services_use_final_cloud_managed_http_policy() -> color_eyre::Result<()> { - for (configured_respect_system_proxy, managed_respect_system_proxy) in - [(true, false), (false, true)] - { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join("config.toml"), - format!("[features]\nrespect_system_proxy = {configured_respect_system_proxy}\n"), - )?; - let prepared_environment_manager = - EnvironmentManager::prepare_from_codex_home(codex_home.path()).await?; - let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); - let bootstrap_config = load_config_toml_with_layer_stack( - codex_home.path(), - /*cwd*/ None, - Vec::new(), - codex_config::ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - ..Default::default() - }, - ) - .await?; - let bootstrap_http_client_factory = resolve_bootstrap_http_client_factory( - &bootstrap_config.config_toml, - bootstrap_config - .config_layer_stack - .requirements() - .feature_requirements - .as_ref(), - )?; - let cloud_config_bundle = - codex_config::test_support::CloudConfigBundleFixture::loader_with_enterprise_requirement( - format!("[features]\nrespect_system_proxy = {managed_respect_system_proxy}\n"), - ); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .loader_overrides(loader_overrides) - .cloud_config_bundle(cloud_config_bundle) - .build() - .await?; - let runtime_paths = ExecServerRuntimeOptions::new( - std::env::current_exe()?, - /*codex_linux_sandbox_exe*/ None, - )?; - let environment_manager = prepared_environment_manager - .build(Some(runtime_paths), config.http_client_factory())?; - - assert_ne!( - bootstrap_http_client_factory.outbound_proxy_policy(), - config.http_client_factory().outbound_proxy_policy() - ); - assert_eq!( - environment_manager - .http_client_factory() - .outbound_proxy_policy(), - config.http_client_factory().outbound_proxy_policy() - ); - assert_eq!( - config - .auth_route_config() - .http_client_factory() - .outbound_proxy_policy(), - config.http_client_factory().outbound_proxy_policy() - ); - assert_eq!(config.respect_system_proxy, managed_respect_system_proxy); - } - - Ok(()) - } - - pub(crate) async fn start_test_embedded_app_server( - config: Config, - ) -> color_eyre::Result { - let state_db = - init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; - start_embedded_app_server( - Arg0DispatchPaths::default(), - config, - Vec::new(), - LoaderOverrides::default(), - /*strict_config*/ false, - CloudConfigBundleLoader::default(), - codex_feedback::CodexFeedback::new(), - /*log_db*/ None, - state_db, - Arc::new(EnvironmentManager::default_for_tests()), - Default::default(), - ) - .await - } - - #[tokio::test] - async fn startup_resume_rejects_guardian_thread() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let thread_id = write_session_rollout( - temp_dir.path(), - "2025-01-05T12-00-00", - "2025-01-05T12:00:00Z", - "Guardian review", - &config.model_provider_id, - config.cwd.as_path(), - )?; - let rollout_path = temp_dir - .path() - .join("sessions/2025/01/05") - .join(format!("rollout-2025-01-05T12-00-00-{thread_id}.jsonl")); - let rollout = std::fs::read_to_string(&rollout_path)?; - let (meta, history) = rollout.split_once('\n').expect("rollout has metadata"); - let mut meta: serde_json::Value = serde_json::from_str(meta)?; - meta["payload"]["source"] = serde_json::json!({"subagent": {"other": "guardian"}}); - meta["payload"]["parent_thread_id"] = - serde_json::json!("00000000-0000-0000-0000-000000000001"); - std::fs::write(&rollout_path, format!("{meta}\n{history}"))?; - - let state_db = - init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; - let mut app_server = start_app_server_for_picker( - crate::backend_id::BackendId::new(), - &config, - &AppServerTarget::Embedded, - Vec::new(), - LoaderOverrides::without_managed_config_for_tests(), - state_db, - Arc::new(EnvironmentManager::default_for_tests()), - ) - .await?; - let error = session_resume::validate_resume_target(&mut app_server, thread_id) - .await - .expect_err("Guardian resume should be rejected"); - insta::assert_snapshot!(error.to_string(), @" - Cannot resume a Guardian review thread. - Resume its parent session instead: codex resume 00000000-0000-0000-0000-000000000001 - "); - app_server.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> { - let backend_id = crate::backend_id::BackendId::new(); - for (action, configured_mode, has_explicit_cwd, expected_directory) in [ - (CwdPromptAction::Resume, "current", false, "launch"), - (CwdPromptAction::Resume, "session", false, "session"), - (CwdPromptAction::Resume, "session", true, "explicit"), - (CwdPromptAction::Fork, "current", false, "launch"), - (CwdPromptAction::Fork, "session", false, "session"), - (CwdPromptAction::Fork, "session", true, "explicit"), - ] { - let temp_dir = TempDir::new()?; - let codex_home = temp_dir.path().join("codex-home"); - let launch_cwd = temp_dir.path().join("launch"); - let session_cwd = temp_dir.path().join("session"); - let explicit_cwd = temp_dir.path().join("explicit"); - std::fs::create_dir_all(&codex_home)?; - std::fs::create_dir_all(&launch_cwd)?; - std::fs::create_dir_all(&session_cwd)?; - std::fs::create_dir_all(&explicit_cwd)?; - std::fs::write( - codex_home.join("config.toml"), - format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), - )?; - let cwd_override = has_explicit_cwd.then_some(explicit_cwd.as_path()); - let config = ConfigBuilder::default() - .codex_home(codex_home.clone()) - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd_override.unwrap_or(launch_cwd.as_path()).to_path_buf()), - ..Default::default() - }) - .build() - .await?; - let filename_timestamp = "2025-01-05T12-00-00"; - let thread_id = write_session_rollout( - &codex_home, - filename_timestamp, - "2025-01-05T12:00:00Z", - "Saved user message", - &config.model_provider_id, - &session_cwd, - )?; - let rollout_path = codex_home - .join("sessions/2025/01/05") - .join(format!("rollout-{filename_timestamp}-{thread_id}.jsonl")); - let state_db = - init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; - let target_session = resume_picker::SessionTarget { - backend_id, - path: Some(rollout_path), - thread_id, - cwd: Some(session_cwd.clone()), - history_mode: None, - }; - let session_selection = match action { - CwdPromptAction::Resume => resume_picker::SessionSelection::Resume(target_session), - CwdPromptAction::Fork => resume_picker::SessionSelection::Fork(target_session), - }; - let mut tui = tui::test_support::make_test_tui()?; - - let fallback_cwd = match resolve_startup_resume_or_fork_cwd( - backend_id, - &mut tui, - &config, - /*app_server*/ None, - &session_selection, - cwd_override, - /*uses_remote_workspace*/ false, - /*uses_remote_workspace_or_environment*/ false, - ) - .await? - { - ResolveCwdOutcome::Continue(cwd) => cwd, - ResolveCwdOutcome::ContinueAfterPrompt(_) => { - panic!("configured cwd should not prompt during startup") - } - ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"), - }; - let final_config = ConfigBuilder::default() - .codex_home(codex_home) - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .harness_overrides(ConfigOverrides { - cwd: cwd_override.map(Path::to_path_buf), - ..Default::default() - }) - .fallback_cwd(fallback_cwd) - .build() - .await?; - let expected_cwd = temp_dir.path().join(expected_directory); - assert!(!session_resume::cwds_differ( - final_config.cwd.as_path(), - &expected_cwd, - )); - let mut app_server = start_app_server_for_picker( - crate::backend_id::BackendId::new(), - &final_config, - &AppServerTarget::Embedded, - Vec::new(), - LoaderOverrides::without_managed_config_for_tests(), - state_db, - Arc::new(EnvironmentManager::default_for_tests()), - ) - .await?; - let started = match action { - CwdPromptAction::Resume => { - app_server - .resume_thread( - &crate::local_settings::LocalSettings::from(&final_config), - final_config, - thread_id, - app_server_session::ResumeModelSettings::RestoreFromThread, - ) - .await? - } - CwdPromptAction::Fork => { - app_server - .fork_thread( - &crate::local_settings::LocalSettings::from(&final_config), - final_config, - thread_id, - ) - .await? - } - }; - - assert!(!session_resume::cwds_differ( - started.session.cwd.as_path(), - &expected_cwd, - )); - app_server.shutdown().await?; - } - - Ok(()) - } - - #[tokio::test] - async fn startup_remote_current_cwd_without_override_is_rejected() -> color_eyre::Result<()> { - let backend_id = crate::backend_id::BackendId::new(); - let temp_dir = TempDir::new()?; - std::fs::write( - temp_dir.path().join("config.toml"), - "[tui]\nresume_cwd = \"current\"\n", - )?; - let config = ConfigBuilder::default() - .codex_home(temp_dir.path().to_path_buf()) - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .build() - .await?; - let mut tui = tui::test_support::make_test_tui()?; - - let error = resolve_startup_resume_or_fork_cwd( - backend_id, - &mut tui, - &config, - /*app_server*/ None, - &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { - backend_id, - path: None, - thread_id: ThreadId::new(), - cwd: None, - history_mode: None, - }), - /*cwd_override*/ None, - /*uses_remote_workspace*/ false, - /*uses_remote_workspace_or_environment*/ true, - ) - .await - .expect_err("remote current cwd should require an explicit override"); - - assert_eq!( - error.to_string(), - "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" - ); - let explicit = resolve_startup_resume_or_fork_cwd( - backend_id, - &mut tui, - &config, - /*app_server*/ None, - &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { - backend_id, - path: None, - thread_id: ThreadId::new(), - cwd: None, - history_mode: None, - }), - Some(Path::new("/remote-only/project")), - /*uses_remote_workspace*/ true, - /*uses_remote_workspace_or_environment*/ true, - ) - .await?; - assert!( - matches!(explicit, ResolveCwdOutcome::Continue(Some(cwd)) if cwd == config.cwd.as_path()) - ); - Ok(()) - } - - #[tokio::test] - async fn startup_session_cwd_without_metadata_is_rejected() -> color_eyre::Result<()> { - let backend_id = crate::backend_id::BackendId::new(); - let temp_dir = TempDir::new()?; - std::fs::write( - temp_dir.path().join("config.toml"), - "[tui]\nresume_cwd = \"session\"\n", - )?; - let config = ConfigBuilder::default() - .codex_home(temp_dir.path().to_path_buf()) - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .build() - .await?; - let mut tui = tui::test_support::make_test_tui()?; - - let error = resolve_startup_resume_or_fork_cwd( - backend_id, - &mut tui, - &config, - /*app_server*/ None, - &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { - backend_id, - path: None, - thread_id: ThreadId::new(), - cwd: None, - history_mode: None, - }), - /*cwd_override*/ None, - /*uses_remote_workspace*/ false, - /*uses_remote_workspace_or_environment*/ false, - ) - .await - .expect_err("session cwd should require saved metadata"); - - assert_eq!( - error.to_string(), - "failed to determine the working directory recorded for the selected session" - ); - Ok(()) - } - - #[test] - fn alternate_screen_respects_terminal_compatibility_and_overrides() { - for terminal_app_over_ssh in [false, true] { - for (mode, expected) in [ - (AltScreenMode::Auto, !terminal_app_over_ssh), - (AltScreenMode::Always, true), - (AltScreenMode::Never, false), - ] { - assert_eq!( - determine_alt_screen_mode( - /*no_alt_screen*/ false, - mode, - terminal_app_over_ssh - ), - expected, - ); - assert!(!determine_alt_screen_mode( - /*no_alt_screen*/ true, - mode, - terminal_app_over_ssh - )); - } - } - } - - #[test] - fn session_target_display_label_falls_back_to_thread_id() { - let thread_id = ThreadId::new(); - let target = crate::resume_picker::SessionTarget { - backend_id: crate::backend_id::BackendId::new(), - path: None, - thread_id, - cwd: None, - history_mode: None, - }; - - assert_eq!(target.display_label(), format!("thread {thread_id}")); - } - - #[test] - fn resolve_remote_addr_accepts_websocket_url() { - assert_eq!( - resolve_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"), - RemoteAppServerEndpoint::WebSocket { - websocket_url: "ws://127.0.0.1:4500/".to_string(), - auth_token: None, - } - ); - } - - #[test] - fn resolve_remote_addr_accepts_secure_websocket_url() { - assert_eq!( - resolve_remote_addr("wss://example.com:443").expect("wss URL should normalize"), - RemoteAppServerEndpoint::WebSocket { - websocket_url: "wss://example.com/".to_string(), - auth_token: None, - } - ); - } - - #[test] - fn resolve_remote_addr_accepts_default_socket() -> color_eyre::Result<()> { - let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?; - assert_eq!( - resolve_remote_addr("unix://")?, - RemoteAppServerEndpoint::UnixSocket { - socket_path: codex_app_server_client::app_server_control_socket_path(&codex_home)?, - } - ); - Ok(()) - } - - #[test] - fn resolve_remote_addr_accepts_relative_socket_path() -> color_eyre::Result<()> { - assert_eq!( - resolve_remote_addr("unix://codex.sock")?, - RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, - } - ); - Ok(()) - } - - #[test] - fn resolve_remote_addr_accepts_absolute_socket_path() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let socket_path = temp_dir.path().join("codex.sock"); - assert_eq!( - resolve_remote_addr(&format!("unix://{}", socket_path.display()))?, - RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::from_absolute_path(&socket_path)?, - } - ); - Ok(()) - } - - #[test] - fn resolve_remote_addr_rejects_invalid_remote_addresses() { - for addr in [ - "ws://127.0.0.1", - "wss://example.com", - "127.0.0.1:4500", - "https://127.0.0.1:4500", - ] { - let err = resolve_remote_addr(addr).expect_err("invalid remote addresses should fail"); - assert!(err.to_string().contains( - "expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" - )); - } - } - - #[tokio::test] - async fn default_daemon_auto_connect_skips_missing_socket() -> color_eyre::Result<()> { - let codex_home = TempDir::new()?; - assert!( - maybe_probe_default_daemon_socket(codex_home.path()) - .await - .is_none() - ); - Ok(()) - } - - #[tokio::test] - async fn default_daemon_auto_connect_probes_socket_only() -> color_eyre::Result<()> { - let codex_home = TempDir::new()?; - let socket_path = - codex_app_server_client::app_server_control_socket_path(codex_home.path())?; - #[cfg(windows)] - { - let parent = socket_path.as_path().parent().expect("socket parent"); - std::fs::create_dir_all(parent)?; - let listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; - assert!( - maybe_probe_default_daemon_socket(codex_home.path()) - .await - .is_none() - ); - drop(listener); - std::fs::remove_dir_all(parent)?; - } - codex_uds::prepare_private_socket_directory( - socket_path.as_path().parent().expect("socket parent"), - ) - .await?; - let _listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; - - let expected = Some(socket_path); - #[cfg(windows)] - let expected = { - // Existing Windows CI may run elevated; an elevated listener must - // never be selected implicitly, even when its directory is private. - let output = std::process::Command::new("powershell.exe") - .args(["-NoProfile", "-NonInteractive", "-Command", "([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"]) - .output()?; - assert!(output.status.success()); - match String::from_utf8(output.stdout)?.trim() { - "True" => None, - "False" => expected, - other => panic!("unexpected elevation result: {other}"), - } - }; - assert_eq!( - maybe_probe_default_daemon_socket(codex_home.path()).await, - expected - ); - Ok(()) - } - - #[test] - fn app_server_target_for_launch_uses_local_daemon_for_default_socket() -> color_eyre::Result<()> - { - let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; - let endpoint = RemoteAppServerEndpoint::UnixSocket { - socket_path: socket_path.clone(), - }; - for explicit in [false, true] { - let target = app_server_target_for_launch( - explicit.then_some(endpoint.clone()), - Some(socket_path.clone()), - /*can_reuse_implicit_local_daemon*/ !explicit, - /*workload_identity_selected*/ false, - /*exec_server_url*/ None, - )?; - assert_eq!( - target, - AppServerTarget::LocalDaemon { - allow_embedded_fallback: !explicit, - endpoint: endpoint.clone(), - } - ); - assert!(!target.uses_remote_workspace()); - assert_eq!(target.thread_params_mode(), ThreadParamsMode::Embedded); - } - Ok(()) - } - - #[test] - fn app_server_target_for_launch_preserves_executor_selection() -> color_eyre::Result<()> { - let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; - for executor in ["none", "ws://127.0.0.1:4501"] { - assert_eq!( - app_server_target_for_launch( - /*explicit_remote_endpoint*/ None, - Some(socket_path.clone()), - /*can_reuse_implicit_local_daemon*/ true, - /*workload_identity_selected*/ false, - Some(std::ffi::OsStr::new(executor)), - )?, - AppServerTarget::Embedded, - ); - } - Ok(()) - } - - #[test] - fn app_server_target_for_launch_prefers_explicit_remote_endpoint() -> color_eyre::Result<()> { - let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, - }; - let target = app_server_target_for_launch( - Some(explicit_endpoint.clone()), - Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?), - /*can_reuse_implicit_local_daemon*/ false, - /*workload_identity_selected*/ false, - Some(std::ffi::OsStr::new("none")), - )?; - - assert_eq!( - target, - AppServerTarget::Remote { - endpoint: explicit_endpoint, - } - ); - assert!(target.uses_remote_workspace()); - assert_eq!(target.thread_params_mode(), ThreadParamsMode::Remote); - Ok(()) - } - - #[test] - fn app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable() - -> color_eyre::Result<()> { - let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; - let target = app_server_target_for_launch( - /*explicit_remote_endpoint*/ None, - Some(socket_path), - /*can_reuse_implicit_local_daemon*/ false, - /*workload_identity_selected*/ false, - /*exec_server_url*/ None, - )?; - - assert_eq!(target, AppServerTarget::Embedded); - Ok(()) - } - - #[test] - fn workload_identity_requires_an_embedded_app_server() -> color_eyre::Result<()> { - let default_socket = AbsolutePathBuf::relative_to_current_dir("default.sock")?; - assert_eq!( - app_server_target_for_launch( - /*explicit_remote_endpoint*/ None, - Some(default_socket), - /*can_reuse_implicit_local_daemon*/ true, - /*workload_identity_selected*/ true, - /*exec_server_url*/ None, - )?, - AppServerTarget::Embedded - ); - - let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, - }; - let error = app_server_target_for_launch( - Some(explicit_endpoint), - /*default_daemon_socket*/ None, - /*can_reuse_implicit_local_daemon*/ false, - /*workload_identity_selected*/ true, - /*exec_server_url*/ None, - ) - .expect_err("remote hosts must own workload identity"); - assert_eq!( - error.to_string(), - "workload identity must be configured on the remote app-server host" - ); - Ok(()) - } - - #[test] - fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { - let target = AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, - }, - }; - - assert!(should_load_configured_environments( - &LoaderOverrides::default(), - &target, - )); - Ok(()) - } - - #[tokio::test] - async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions() - -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let cwd = temp_dir.path().join("project"); - - let params = latest_session_lookup_params( - /*uses_remote_filesystem*/ false, - /*model_provider*/ None, - Some(cwd.as_path()), - /*include_non_interactive*/ false, - LatestSessionLookupMode::StateDbOnly, - ); - - assert_eq!(params.model_providers, None); - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) - ); - assert!(params.use_state_db_only); - - let scan_params = latest_session_lookup_params( - /*uses_remote_filesystem*/ false, - /*model_provider*/ None, - Some(cwd.as_path()), - /*include_non_interactive*/ false, - LatestSessionLookupMode::ScanAndRepair, - ); - assert!(!scan_params.use_state_db_only); - Ok(()) - } - - #[tokio::test] - async fn latest_session_lookup_params_honor_explicit_provider() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let cwd = temp_dir.path().join("project"); - let params = latest_session_lookup_params( - /*uses_remote_filesystem*/ false, - Some("selected-provider".to_string()), - Some(cwd.as_path()), - /*include_non_interactive*/ false, - LatestSessionLookupMode::StateDbOnly, - ); - - assert_eq!( - params.model_providers, - Some(vec!["selected-provider".to_string()]) - ); - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) - ); - Ok(()) - } - - #[tokio::test] - async fn latest_session_lookup_params_omit_local_filters_for_remote_sessions() - -> std::io::Result<()> { - let params = latest_session_lookup_params( - /*uses_remote_filesystem*/ true, - /*model_provider*/ None, - /*cwd_filter*/ None, - /*include_non_interactive*/ false, - LatestSessionLookupMode::StateDbOnly, - ); - - assert_eq!(params.model_providers, None); - assert_eq!(params.cwd, None); - Ok(()) - } - - #[tokio::test] - async fn latest_session_lookup_params_can_include_non_interactive_sources() - -> std::io::Result<()> { - let params = latest_session_lookup_params( - /*uses_remote_filesystem*/ true, - /*model_provider*/ None, - /*cwd_filter*/ None, - /*include_non_interactive*/ true, - LatestSessionLookupMode::StateDbOnly, - ); - - assert_eq!( - params.source_kinds, - Some(vec![ - ThreadSourceKind::Cli, - ThreadSourceKind::VsCode, - ThreadSourceKind::Exec, - ThreadSourceKind::AppServer, - ]) - ); - Ok(()) - } - - #[tokio::test] - async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessions() - -> std::io::Result<()> { - let cwd = Path::new("repo/on/server"); - - let params = latest_session_lookup_params( - /*uses_remote_filesystem*/ true, - /*model_provider*/ None, - Some(cwd), - /*include_non_interactive*/ false, - LatestSessionLookupMode::StateDbOnly, - ); - - assert_eq!(params.model_providers, None); - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::One(String::from("repo/on/server"))) - ); - Ok(()) - } - - #[tokio::test] - async fn latest_session_cwd_filter_respects_scope_options() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let remote_cwd = Path::new("repo/on/server"); - - let local_filter = latest_session_cwd_filter( - /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, - /*show_all*/ false, - ); - let show_all_filter = latest_session_cwd_filter( - /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, - /*show_all*/ true, - ); - let remote_filter = latest_session_cwd_filter( - /*uses_remote_workspace*/ true, - Some(remote_cwd), - &config, - /*show_all*/ false, - ); - - assert_eq!(local_filter, Some(config.cwd.as_path())); - assert_eq!(show_all_filter, None); - assert_eq!(remote_filter, Some(remote_cwd)); - Ok(()) - } - - #[tokio::test] - async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let project_cwd = temp_dir.path().join("project"); - let linked_cwd = temp_dir.path().join("linked-project"); - let other_cwd = temp_dir.path().join("other-project"); - let admin = project_cwd.join(".git/worktrees/linked"); - std::fs::create_dir_all(&admin)?; - std::fs::create_dir_all(&linked_cwd)?; - std::fs::create_dir_all(&other_cwd)?; - std::fs::write(project_cwd.join(".git/HEAD"), "ref: refs/heads/main\n")?; - std::fs::write(admin.join("commondir"), "../..\n")?; - std::fs::write( - admin.join("gitdir"), - linked_cwd.join(".git").display().to_string(), - )?; - std::fs::write( - linked_cwd.join(".git"), - format!("gitdir: {}", admin.display()), - )?; - - let mut config = ConfigBuilder::default() - .codex_home(temp_dir.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(project_cwd.clone()), - ..Default::default() - }) - .build() - .await?; - config - .features - .enable(codex_features::Feature::LocalThreadStoreCompression)?; - let model_provider = config.model_provider_id.as_str(); - let project_thread_id = write_session_rollout( - temp_dir.path(), - "2025-01-02T10-00-00", - "2025-01-02T10:00:00Z", - "older project session", - model_provider, - &project_cwd, - )?; - let linked_thread_id = write_session_rollout( - temp_dir.path(), - "2025-01-02T11-00-00", - "2025-01-02T11:00:00Z", - "newer linked-worktree session", - model_provider, - &linked_cwd, - )?; - let other_thread_id = write_session_rollout( - temp_dir.path(), - "2025-01-02T12-00-00", - "2025-01-02T12:00:00Z", - "newer other project session", - model_provider, - &other_cwd, - )?; - let rollout_paths = [ - ("2025-01-02T10-00-00", project_thread_id), - ("2025-01-02T11-00-00", linked_thread_id), - ("2025-01-02T12-00-00", other_thread_id), - ] - .map(|(timestamp, thread_id)| { - app_test_support::rollout_path(temp_dir.path(), timestamp, &thread_id.to_string()) - }); - - let mut app_server = AppServerSession::new( - crate::backend_id::BackendId::new(), - codex_app_server_client::AppServerClient::InProcess( - start_test_embedded_app_server(config.clone()).await?, - ), - ThreadParamsMode::Embedded, - ); - // Exercise the logical .jsonl paths returned by thread/list after startup compression. - tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { - while rollout_paths - .iter() - .any(|path| path.exists() || !path.with_extension("jsonl.zst").exists()) - { - tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 10)).await; - } - }) - .await?; - let filter_cwd = latest_session_cwd_filter( - /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, - /*show_all*/ false, - ); - let scoped_target = lookup_latest_session_target_with_app_server( - /*uses_remote_filesystem*/ false, - &mut app_server, - &config, - filter_cwd, - /*include_non_interactive*/ false, - ) - .await? - .expect("expected project-scoped fork --last target"); - let show_all_filter_cwd = latest_session_cwd_filter( - /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, - /*show_all*/ true, - ); - let show_all_target = lookup_latest_session_target_with_app_server( - /*uses_remote_filesystem*/ false, - &mut app_server, - &config, - show_all_filter_cwd, - /*include_non_interactive*/ false, - ) - .await? - .expect("expected global fork --last target"); - app_server.shutdown().await?; - - assert_eq!(scoped_target.thread_id, linked_thread_id); - assert_eq!(show_all_target.thread_id, other_thread_id); - Ok(()) - } - - #[tokio::test] - async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db() - -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let project_cwd = temp_dir.path().join("project"); - std::fs::create_dir_all(&project_cwd)?; - let config = ConfigBuilder::default() - .codex_home(temp_dir.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(project_cwd.clone()), - ..Default::default() - }) - .build() - .await?; - let mut app_server = AppServerSession::new( - crate::backend_id::BackendId::new(), - codex_app_server_client::AppServerClient::InProcess( - start_test_embedded_app_server(config.clone()).await?, - ), - ThreadParamsMode::Embedded, - ); - - // Simulate a legacy writer creating a rollout after the state DB backfill completed. - let thread_id = write_session_rollout( - temp_dir.path(), - "2025-01-02T10-00-00", - "2025-01-02T10:00:00Z", - "legacy writer session", - config.model_provider_id.as_str(), - &project_cwd, - )?; - - let target = lookup_latest_session_target_with_app_server( - /*uses_remote_filesystem*/ false, - &mut app_server, - &config, - Some(project_cwd.as_path()), - /*include_non_interactive*/ false, - ) - .await? - .expect("expected scan-and-repair fallback to find the rollout"); - app_server.shutdown().await?; - - assert_eq!(target.thread_id, thread_id); - Ok(()) - } - - #[tokio::test] - async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> - { - let remote_only_cwd = if cfg!(windows) { - Path::new(r"C:\definitely\not\local\to\this\test") - } else { - Path::new("/definitely/not/local/to/this/test") - }; - let target = AppServerTarget::Remote { - endpoint: RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, - }, - }; - let environment_manager = EnvironmentManager::default_for_tests(); - - let config_cwd = config_cwd_for_app_server_target( - Some(remote_only_cwd), - &target, - environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()), - )?; - - assert_eq!(config_cwd, None); - Ok(()) - } - - #[tokio::test] - async fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> - { - let temp_dir = TempDir::new()?; - let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::default_for_tests(); - - let config_cwd = config_cwd_for_app_server_target( - Some(temp_dir.path()), - &target, - environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()), - )?; - - assert_eq!( - config_cwd, - Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( - temp_dir.path() - )?)?) - ); - Ok(()) - } - - #[tokio::test] - async fn config_cwd_for_app_server_target_canonicalizes_local_daemon_cli_cwd() - -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let target = AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, - }, - }; - let environment_manager = EnvironmentManager::default_for_tests(); - - let config_cwd = config_cwd_for_app_server_target( - Some(temp_dir.path()), - &target, - environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()), - )?; - - assert_eq!( - config_cwd, - Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( - temp_dir.path() - )?)?) - ); - Ok(()) - } - - #[tokio::test] - async fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() - -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let missing = temp_dir.path().join("missing"); - let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::default_for_tests(); - - let err = config_cwd_for_app_server_target( - Some(&missing), - &target, - environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()), - ) - .expect_err("missing embedded cwd should fail"); - - assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - Ok(()) - } - - #[tokio::test] - async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() - -> std::io::Result<()> { - let remote_only_cwd = if cfg!(windows) { - Path::new(r"C:\definitely\not\local\to\this\test") - } else { - Path::new("/definitely/not/local/to/this/test") - }; - let target = AppServerTarget::Embedded; - let environment_manager = EnvironmentManager::create_for_tests( - Some("ws://127.0.0.1:8765".to_string()), - Some(ExecServerRuntimeOptions::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - )?), - ) - .await; - - let config_cwd = config_cwd_for_app_server_target( - Some(remote_only_cwd), - &target, - environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()), - )?; - - assert_eq!(config_cwd, None); - assert!(!projectless::has_only_local_environments( - &environment_manager - )); - assert!(projectless::has_only_local_environments( - &EnvironmentManager::default_for_tests() - )); - assert!(uses_remote_workspace_or_environment( - &target, - &environment_manager - )); - let local_daemon = AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, - }, - }; - assert!(uses_remote_workspace_or_environment( - &local_daemon, - &environment_manager - )); - Ok(()) - } - - #[tokio::test] - async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let app_server = start_test_embedded_app_server(config).await?; - let response: ThreadStartResponse = app_server - .request_typed(ClientRequest::ThreadStart { - request_id: RequestId::Integer(1), - params: ThreadStartParams { - ephemeral: Some(true), - ..ThreadStartParams::default() - }, - }) - .await - .expect("thread/start should succeed"); - assert!(!response.thread.id.is_empty()); - - app_server.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn resume_picker_loads_complete_paginated_and_legacy_transcripts() - -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.terminal_resize_reflow.max_rows = - crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(2); - let mut app_server = AppServerSession::new( - crate::backend_id::BackendId::new(), - AppServerClient::InProcess(start_test_embedded_app_server(config.clone()).await?), - ThreadParamsMode::Embedded, - ); - let filename_ts = "2025-01-05T12-00-00"; - let rollout_line = |ordinal: usize, payload: serde_json::Value| { - serde_json::json!({ - "timestamp": "2025-01-05T12:00:00Z", - "type": "event_msg", - "payload": payload, - "ordinal": ordinal, - }) - }; - for (history_mode, create_rollout) in [ - app_test_support::create_fake_rollout, - app_test_support::create_fake_paginated_rollout, - ] - .into_iter() - .enumerate() - { - let thread_id = create_rollout( - temp_dir.path(), - filename_ts, - "2025-01-05T12:00:00Z", - "message 0", - Some(config.model_provider_id.as_str()), - /*git_info*/ None, - ) - .expect("create session rollout"); - let path = app_test_support::rollout_path(temp_dir.path(), filename_ts, &thread_id); - let mut contents = std::fs::read_to_string(&path)?; - let started = rollout_line( - /*ordinal*/ 3, - serde_json::json!({ "type": "task_started", "turn_id": "history-turn", "model_context_window": null }), - ); - contents.push_str(&format!("{started}\n")); - for index in 0..=100 { - let message = format!("message {index}"); - let payload = if history_mode == 1 { - serde_json::json!({ - "type": "item_completed", - "thread_id": thread_id, - "turn_id": "history-turn", - "item": { "type": "UserMessage", "id": format!("user-{index}"), - "content": [{ "type": "text", "text": message }] }, - }) - } else { - serde_json::json!({ "type": "user_message", "message": message }) - }; - let item = rollout_line(index + 4, payload); - contents.push_str(&format!("{item}\n")); - } - if history_mode == 1 { - for index in 0..125 { - let item = rollout_line( - index + 105, - serde_json::json!({ - "type": "item_completed", - "thread_id": thread_id, - "turn_id": "history-turn", - "item": { "type": "Reasoning", "id": format!("hidden-{index}"), - "summary_text": [], "raw_content": [] }, - }), - ); - contents.push_str(&format!("{item}\n")); - } - } - std::fs::write(path, contents)?; - let thread_id = ThreadId::from_string(&thread_id)?; - let started = app_server - .resume_thread( - &crate::local_settings::LocalSettings::from(&config), - config.clone(), - thread_id, - app_server_session::ResumeModelSettings::RestoreFromThread, - ) - .await?; - if history_mode == 1 { - assert!( - started - .turns - .iter() - .flat_map(|turn| &turn.items) - .any(|item| { - matches!( - item, - codex_app_server_protocol::ThreadItem::UserMessage { .. } - ) - }) - ); - let preview = crate::resume_picker::load_transcript_preview( - &mut app_server, - thread_id, - /*config*/ None, - ) - .await?; - assert!(!preview.is_empty()); - } - let cells = crate::thread_transcript::load_session_transcript( - &mut app_server, - thread_id, - crate::thread_transcript::RawReasoningVisibility::Hidden, - /*config*/ None, - ) - .await?; - assert!(cells.len() > 100); - } - app_server.shutdown().await?; - Ok(()) - } - - #[tokio::test] - async fn embedded_app_server_start_failure_is_returned() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let result = start_embedded_app_server_with( - Arg0DispatchPaths::default(), - config, - Vec::new(), - LoaderOverrides::default(), - /*strict_config*/ false, - CloudConfigBundleLoader::default(), - codex_feedback::CodexFeedback::new(), - /*log_db*/ None, - /*state_db*/ None, - Arc::new(EnvironmentManager::default_for_tests()), - Default::default(), - |_args| async { Err(std::io::Error::other("boom")) }, - ) - .await; - let err = match result { - Ok(_) => panic!("startup failure should be returned"), - Err(err) => err, - }; - - assert!( - err.to_string() - .contains("failed to start embedded app server"), - "error should preserve the embedded app server startup context" - ); - Ok(()) - } - - #[tokio::test] - async fn embedded_state_db_failure_is_typed_for_cli_recovery() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - let occupied_sqlite_home = temp_dir.path().join("sqlite-home"); - std::fs::write(&occupied_sqlite_home, "occupied")?; - let sqlite = - codex_state::SqliteConfig::new_for_testing(occupied_sqlite_home.as_path().abs()); - config.sqlite = sqlite.clone(); - - let err = - match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { - Ok(_) => panic!("embedded startup should surface state db init failures"), - Err(err) => err, - }; - let startup_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .expect("state db startup failure should retain its typed context"); - - assert_eq!( - startup_error.state_db_path(), - sqlite.state_db_path().as_path() - ); - assert!( - startup_error - .detail() - .contains("failed to initialize state runtime"), - "startup error should preserve the underlying state db failure" - ); - Ok(()) - } - - #[tokio::test] - async fn embedded_state_db_corruption_preserves_failed_database_for_cli_recovery() - -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - let sqlite_home = temp_dir.path().join("sqlite-home"); - std::fs::create_dir_all(&sqlite_home)?; - let sqlite = codex_state::SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); - let logs_db_path = sqlite.logs_db_path(); - std::fs::write(&logs_db_path, "not a sqlite database")?; - config.sqlite = sqlite; - - let err = - match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { - Ok(_) => panic!("embedded startup should surface state db init failures"), - Err(err) => err, - }; - let startup_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .expect("state db startup failure should retain its typed context"); - - assert_eq!(startup_error.database_path(), logs_db_path.as_path()); - assert!( - startup_error.is_corruption(), - "startup error should preserve the SQLite corruption cause, got: {}", - startup_error.detail() - ); - Ok(()) - } - - #[tokio::test] - async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let codex_home = temp_dir.path().to_path_buf(); - let trusted = temp_dir.path().join("trusted"); - let untrusted = temp_dir.path().join("untrusted"); - std::fs::create_dir_all(&trusted)?; - std::fs::create_dir_all(&untrusted)?; - - // TOML keys need escaped backslashes on Windows paths. - let trusted_display = trusted.display().to_string().replace('\\', "\\\\"); - let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\"); - let config_toml = format!( - r#"[projects."{trusted_display}"] -trust_level = "trusted" - -[projects."{untrusted_display}"] -trust_level = "untrusted" -"# - ); - std::fs::write(temp_dir.path().join("config.toml"), config_toml)?; - - let trusted_overrides = ConfigOverrides { - cwd: Some(trusted.clone()), - ..Default::default() - }; - let trusted_config = ConfigBuilder::default() - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .codex_home(codex_home.clone()) - .harness_overrides(trusted_overrides.clone()) - .build() - .await?; - assert_eq!( - AskForApproval::from(trusted_config.permissions.approval_policy.value()), - AskForApproval::OnRequest - ); - - let untrusted_overrides = ConfigOverrides { - cwd: Some(untrusted), - ..trusted_overrides - }; - let untrusted_config = ConfigBuilder::default() - .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) - .codex_home(codex_home) - .harness_overrides(untrusted_overrides) - .build() - .await?; - assert_eq!( - AskForApproval::from(untrusted_config.permissions.approval_policy.value()), - AskForApproval::UnlessTrusted - ); - Ok(()) - } - - /// Regression: theme must be configured from the *final* config. - /// - /// `run_ratatui_app` can reload config during onboarding and again - /// during session resume/fork. The syntax theme override (stored in - /// a `OnceLock`) must use the final config's `tui_theme`, not the - /// initial one — otherwise users resuming a thread in a project with - /// a different theme get the wrong highlighting. - /// - /// We verify the invariant indirectly: `validate_theme_name` (the - /// pure validation core of `set_theme_override`) must be called with - /// the *final* config's theme, and its warning must land in the - /// final config's `startup_warnings`. - #[tokio::test] - async fn theme_warning_uses_final_config() -> std::io::Result<()> { - use crate::render::highlight::validate_theme_name; - - let temp_dir = TempDir::new()?; - - // initial_config has a valid theme — no warning. - let initial_config = build_config(&temp_dir).await?; - assert!(initial_config.tui_theme.is_none()); - - // Simulate resume/fork reload: the final config has an invalid theme. - let mut config = build_config(&temp_dir).await?; - config.tui_theme = Some("bogus-theme".into()); - - // Theme override must use the final config (not initial_config). - // This mirrors the real call site in run_ratatui_app. - if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) { - config.startup_warnings.push(w); - } - - assert_eq!( - config.startup_warnings.len(), - 1, - "warning from final config's invalid theme should be present" - ); - assert!( - config.startup_warnings[0].contains("bogus-theme"), - "warning should reference the final config's theme name" - ); - Ok(()) - } -} +pub(crate) use tests::test_backend; +#[cfg(test)] +pub(crate) use tests::test_support; diff --git a/codex-rs/tui/src/lib_tests.rs b/codex-rs/tui/src/lib_tests.rs new file mode 100644 index 0000000000..77702ac453 --- /dev/null +++ b/codex-rs/tui/src/lib_tests.rs @@ -0,0 +1,1726 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use clap::Parser; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_utils_absolute_path::test_support::PathExt; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn tui_startup_future_stays_bounded() { + let future = run_main( + Cli::parse_from(["codex"]), + Arg0DispatchPaths::default(), + LoaderOverrides::default(), + /*explicit_remote_endpoint*/ None, + ); + let size = std::mem::size_of_val(&future); + + assert!(size < 64 * 1024, "TUI startup future is {size} bytes"); +} + +async fn build_config(temp_dir: &TempDir) -> std::io::Result { + ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await +} + +#[tokio::test] +async fn server_account_requirement_controls_login_screen() -> color_eyre::Result<()> { + for requires_openai_auth in [false, true] { + let home = TempDir::new()?; + std::fs::write( + home.path().join("config.toml"), + format!( + r#" +model_provider = "test-provider" +[model_providers.test-provider] +name = "Test provider" +base_url = "http://example.test/v1" +wire_api = "responses" +requires_openai_auth = {requires_openai_auth} +"# + ), + )?; + let server_config = build_config(&home).await?; + let mut server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::InProcess(start_test_embedded_app_server(server_config).await?), + ThreadParamsMode::Embedded, + ); + let (login_status, account) = get_login_status(&mut server).await?; + assert_eq!(account.requires_openai_auth, requires_openai_auth); + assert_eq!( + should_show_login_screen(login_status, account.requires_openai_auth), + requires_openai_auth + ); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::Chatgpt), + account.requires_openai_auth + )); + server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn bedrock_setup_wizard_requires_eligible_onboarding() -> color_eyre::Result<()> { + let shared_endpoint = RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500/".to_string(), + auth_token: None, + }; + let enabled = "[features]\nbedrock_setup_wizard = true\n"; + + for (label, config_toml, login_status, target, expected) in [ + ( + "enabled by default", + "", + LoginStatus::NotAuthenticated, + AppServerTarget::Embedded, + true, + ), + ( + "explicitly disabled", + "[features]\nbedrock_setup_wizard = false\n", + LoginStatus::NotAuthenticated, + AppServerTarget::Embedded, + false, + ), + ( + "enabled for the default provider", + enabled, + LoginStatus::NotAuthenticated, + AppServerTarget::Embedded, + true, + ), + ( + "explicit provider", + "model_provider = \"openai\"\n[features]\nbedrock_setup_wizard = true\n", + LoginStatus::NotAuthenticated, + AppServerTarget::Embedded, + false, + ), + ( + "forced ChatGPT login", + "forced_login_method = \"chatgpt\"\n[features]\nbedrock_setup_wizard = true\n", + LoginStatus::NotAuthenticated, + AppServerTarget::Embedded, + false, + ), + ( + "existing authentication", + enabled, + LoginStatus::AuthMode(AuthMode::Chatgpt), + AppServerTarget::Embedded, + false, + ), + ( + "shared local daemon", + enabled, + LoginStatus::NotAuthenticated, + AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, + endpoint: shared_endpoint.clone(), + }, + false, + ), + ( + "remote app server", + enabled, + LoginStatus::NotAuthenticated, + AppServerTarget::Remote { + endpoint: shared_endpoint, + }, + false, + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write(codex_home.path().join("config.toml"), config_toml)?; + let config = build_config(&codex_home).await?; + + assert_eq!( + should_show_bedrock_setup_wizard( + login_status, + /*requires_openai_auth*/ true, + &config, + &target + ), + expected, + "{label}" + ); + } + + Ok(()) +} + +pub(crate) fn write_session_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: &str, + cwd: &Path, +) -> color_eyre::Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let thread_id = ThreadId::from_string(&uuid_str)?; + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let rollout_path = codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); + let parent = rollout_path + .parent() + .ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?; + std::fs::create_dir_all(parent)?; + + let session_meta = codex_protocol::protocol::SessionMeta { + session_id: thread_id.into(), + id: thread_id, + timestamp: meta_rfc3339.to_string(), + cwd: cwd.to_path_buf(), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::Cli, + model_provider: Some(model_provider.to_string()), + ..Default::default() + }; + let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { + meta: session_meta, + git: None, + })?; + let lines = [ + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": session_meta, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": preview}], + }, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": preview, + "kind": "plain", + }, + }) + .to_string(), + ]; + std::fs::write(&rollout_path, lines.join("\n") + "\n")?; + let updated_at = + chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = std::fs::FileTimes::new().set_modified(updated_at.into()); + std::fs::OpenOptions::new() + .append(true) + .open(rollout_path)? + .set_times(times)?; + + Ok(thread_id) +} + +#[test] +fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let legacy_log_dir = temp_dir.path().join("log"); + std::fs::create_dir_all(&legacy_log_dir)?; + let legacy_log = legacy_log_dir.join(TUI_LOG_FILE_NAME); + std::fs::write(&legacy_log, "legacy log")?; + + remove_legacy_tui_log_file(temp_dir.path()); + + assert!(!legacy_log.exists()); + Ok(()) +} + +#[tokio::test] +async fn startup_services_use_final_cloud_managed_http_policy() -> color_eyre::Result<()> { + for (configured_respect_system_proxy, managed_respect_system_proxy) in + [(true, false), (false, true)] + { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!("[features]\nrespect_system_proxy = {configured_respect_system_proxy}\n"), + )?; + let prepared_environment_manager = + EnvironmentManager::prepare_from_codex_home(codex_home.path()).await?; + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let bootstrap_config = load_config_toml_with_layer_stack( + codex_home.path(), + /*cwd*/ None, + Vec::new(), + codex_config::ConfigLoadOptions { + loader_overrides: loader_overrides.clone(), + ..Default::default() + }, + ) + .await?; + let bootstrap_http_client_factory = resolve_bootstrap_http_client_factory( + &bootstrap_config.config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; + let cloud_config_bundle = + codex_config::test_support::CloudConfigBundleFixture::loader_with_enterprise_requirement( + format!("[features]\nrespect_system_proxy = {managed_respect_system_proxy}\n"), + ); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(loader_overrides) + .cloud_config_bundle(cloud_config_bundle) + .build() + .await?; + let runtime_paths = ExecServerRuntimeOptions::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?; + let environment_manager = prepared_environment_manager + .build(Some(runtime_paths), config.http_client_factory())?; + + assert_ne!( + bootstrap_http_client_factory.outbound_proxy_policy(), + config.http_client_factory().outbound_proxy_policy() + ); + assert_eq!( + environment_manager + .http_client_factory() + .outbound_proxy_policy(), + config.http_client_factory().outbound_proxy_policy() + ); + assert_eq!( + config + .auth_route_config() + .http_client_factory() + .outbound_proxy_policy(), + config.http_client_factory().outbound_proxy_policy() + ); + assert_eq!(config.respect_system_proxy, managed_respect_system_proxy); + } + + Ok(()) +} + +pub(crate) async fn start_test_embedded_app_server( + config: Config, +) -> color_eyre::Result { + let state_db = init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; + start_embedded_app_server( + Arg0DispatchPaths::default(), + config, + Vec::new(), + LoaderOverrides::default(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + state_db, + Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), + ) + .await +} + +#[tokio::test] +async fn startup_resume_rejects_guardian_thread() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Guardian review", + &config.model_provider_id, + config.cwd.as_path(), + )?; + let rollout_path = temp_dir + .path() + .join("sessions/2025/01/05") + .join(format!("rollout-2025-01-05T12-00-00-{thread_id}.jsonl")); + let rollout = std::fs::read_to_string(&rollout_path)?; + let (meta, history) = rollout.split_once('\n').expect("rollout has metadata"); + let mut meta: serde_json::Value = serde_json::from_str(meta)?; + meta["payload"]["source"] = serde_json::json!({"subagent": {"other": "guardian"}}); + meta["payload"]["parent_thread_id"] = serde_json::json!("00000000-0000-0000-0000-000000000001"); + std::fs::write(&rollout_path, format!("{meta}\n{history}"))?; + + let state_db = init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; + let mut app_server = start_app_server_for_picker( + crate::backend_id::BackendId::new(), + &config, + &AppServerTarget::Embedded, + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + state_db, + Arc::new(EnvironmentManager::default_for_tests()), + ) + .await?; + let error = session_resume::validate_resume_target(&mut app_server, thread_id) + .await + .expect_err("Guardian resume should be rejected"); + insta::assert_snapshot!(error.to_string(), @" + Cannot resume a Guardian review thread. + Resume its parent session instead: codex resume 00000000-0000-0000-0000-000000000001 + "); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn startup_resume_and_fork_use_configured_or_explicit_cwd() -> color_eyre::Result<()> { + let backend_id = crate::backend_id::BackendId::new(); + for (action, configured_mode, has_explicit_cwd, expected_directory) in [ + (CwdPromptAction::Resume, "current", false, "launch"), + (CwdPromptAction::Resume, "session", false, "session"), + (CwdPromptAction::Resume, "session", true, "explicit"), + (CwdPromptAction::Fork, "current", false, "launch"), + (CwdPromptAction::Fork, "session", false, "session"), + (CwdPromptAction::Fork, "session", true, "explicit"), + ] { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let launch_cwd = temp_dir.path().join("launch"); + let session_cwd = temp_dir.path().join("session"); + let explicit_cwd = temp_dir.path().join("explicit"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&launch_cwd)?; + std::fs::create_dir_all(&session_cwd)?; + std::fs::create_dir_all(&explicit_cwd)?; + std::fs::write( + codex_home.join("config.toml"), + format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"), + )?; + let cwd_override = has_explicit_cwd.then_some(explicit_cwd.as_path()); + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd_override.unwrap_or(launch_cwd.as_path()).to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let filename_timestamp = "2025-01-05T12-00-00"; + let thread_id = write_session_rollout( + &codex_home, + filename_timestamp, + "2025-01-05T12:00:00Z", + "Saved user message", + &config.model_provider_id, + &session_cwd, + )?; + let rollout_path = codex_home + .join("sessions/2025/01/05") + .join(format!("rollout-{filename_timestamp}-{thread_id}.jsonl")); + let state_db = + init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; + let target_session = resume_picker::SessionTarget { + backend_id, + path: Some(rollout_path), + thread_id, + cwd: Some(session_cwd.clone()), + history_mode: None, + }; + let session_selection = match action { + CwdPromptAction::Resume => resume_picker::SessionSelection::Resume(target_session), + CwdPromptAction::Fork => resume_picker::SessionSelection::Fork(target_session), + }; + let mut tui = tui::test_support::make_test_tui()?; + + let fallback_cwd = match resolve_startup_resume_or_fork_cwd( + backend_id, + &mut tui, + &config, + /*app_server*/ None, + &session_selection, + cwd_override, + /*uses_remote_workspace*/ false, + /*uses_remote_workspace_or_environment*/ false, + ) + .await? + { + ResolveCwdOutcome::Continue(cwd) => cwd, + ResolveCwdOutcome::ContinueAfterPrompt(_) => { + panic!("configured cwd should not prompt during startup") + } + ResolveCwdOutcome::Exit => panic!("configured cwd should not exit startup"), + }; + let final_config = ConfigBuilder::default() + .codex_home(codex_home) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: cwd_override.map(Path::to_path_buf), + ..Default::default() + }) + .fallback_cwd(fallback_cwd) + .build() + .await?; + let expected_cwd = temp_dir.path().join(expected_directory); + assert!(!session_resume::cwds_differ( + final_config.cwd.as_path(), + &expected_cwd, + )); + let mut app_server = start_app_server_for_picker( + crate::backend_id::BackendId::new(), + &final_config, + &AppServerTarget::Embedded, + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + state_db, + Arc::new(EnvironmentManager::default_for_tests()), + ) + .await?; + let started = match action { + CwdPromptAction::Resume => { + app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&final_config), + final_config, + thread_id, + app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await? + } + CwdPromptAction::Fork => { + app_server + .fork_thread( + &crate::local_settings::LocalSettings::from(&final_config), + final_config, + thread_id, + ) + .await? + } + }; + + assert!(!session_resume::cwds_differ( + started.session.cwd.as_path(), + &expected_cwd, + )); + app_server.shutdown().await?; + } + + Ok(()) +} + +#[tokio::test] +async fn startup_remote_current_cwd_without_override_is_rejected() -> color_eyre::Result<()> { + let backend_id = crate::backend_id::BackendId::new(); + let temp_dir = TempDir::new()?; + std::fs::write( + temp_dir.path().join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let mut tui = tui::test_support::make_test_tui()?; + + let error = resolve_startup_resume_or_fork_cwd( + backend_id, + &mut tui, + &config, + /*app_server*/ None, + &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { + backend_id, + path: None, + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }), + /*cwd_override*/ None, + /*uses_remote_workspace*/ false, + /*uses_remote_workspace_or_environment*/ true, + ) + .await + .expect_err("remote current cwd should require an explicit override"); + + assert_eq!( + error.to_string(), + "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" + ); + let explicit = resolve_startup_resume_or_fork_cwd( + backend_id, + &mut tui, + &config, + /*app_server*/ None, + &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { + backend_id, + path: None, + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }), + Some(Path::new("/remote-only/project")), + /*uses_remote_workspace*/ true, + /*uses_remote_workspace_or_environment*/ true, + ) + .await?; + assert!( + matches!(explicit, ResolveCwdOutcome::Continue(Some(cwd)) if cwd == config.cwd.as_path()) + ); + Ok(()) +} + +#[tokio::test] +async fn startup_session_cwd_without_metadata_is_rejected() -> color_eyre::Result<()> { + let backend_id = crate::backend_id::BackendId::new(); + let temp_dir = TempDir::new()?; + std::fs::write( + temp_dir.path().join("config.toml"), + "[tui]\nresume_cwd = \"session\"\n", + )?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let mut tui = tui::test_support::make_test_tui()?; + + let error = resolve_startup_resume_or_fork_cwd( + backend_id, + &mut tui, + &config, + /*app_server*/ None, + &resume_picker::SessionSelection::Resume(resume_picker::SessionTarget { + backend_id, + path: None, + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }), + /*cwd_override*/ None, + /*uses_remote_workspace*/ false, + /*uses_remote_workspace_or_environment*/ false, + ) + .await + .expect_err("session cwd should require saved metadata"); + + assert_eq!( + error.to_string(), + "failed to determine the working directory recorded for the selected session" + ); + Ok(()) +} + +#[test] +fn alternate_screen_respects_terminal_compatibility_and_overrides() { + for terminal_app_over_ssh in [false, true] { + for (mode, expected) in [ + (AltScreenMode::Auto, !terminal_app_over_ssh), + (AltScreenMode::Always, true), + (AltScreenMode::Never, false), + ] { + assert_eq!( + determine_alt_screen_mode( + /*no_alt_screen*/ false, + mode, + terminal_app_over_ssh + ), + expected, + ); + assert!(!determine_alt_screen_mode( + /*no_alt_screen*/ true, + mode, + terminal_app_over_ssh + )); + } + } +} + +#[test] +fn session_target_display_label_falls_back_to_thread_id() { + let thread_id = ThreadId::new(); + let target = crate::resume_picker::SessionTarget { + backend_id: crate::backend_id::BackendId::new(), + path: None, + thread_id, + cwd: None, + history_mode: None, + }; + + assert_eq!(target.display_label(), format!("thread {thread_id}")); +} + +#[test] +fn resolve_remote_addr_accepts_websocket_url() { + assert_eq!( + resolve_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"), + RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500/".to_string(), + auth_token: None, + } + ); +} + +#[test] +fn resolve_remote_addr_accepts_secure_websocket_url() { + assert_eq!( + resolve_remote_addr("wss://example.com:443").expect("wss URL should normalize"), + RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://example.com/".to_string(), + auth_token: None, + } + ); +} + +#[test] +fn resolve_remote_addr_accepts_default_socket() -> color_eyre::Result<()> { + let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?; + assert_eq!( + resolve_remote_addr("unix://")?, + RemoteAppServerEndpoint::UnixSocket { + socket_path: codex_app_server_client::app_server_control_socket_path(&codex_home)?, + } + ); + Ok(()) +} + +#[test] +fn resolve_remote_addr_accepts_relative_socket_path() -> color_eyre::Result<()> { + assert_eq!( + resolve_remote_addr("unix://codex.sock")?, + RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + } + ); + Ok(()) +} + +#[test] +fn resolve_remote_addr_accepts_absolute_socket_path() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let socket_path = temp_dir.path().join("codex.sock"); + assert_eq!( + resolve_remote_addr(&format!("unix://{}", socket_path.display()))?, + RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path(&socket_path)?, + } + ); + Ok(()) +} + +#[test] +fn resolve_remote_addr_rejects_invalid_remote_addresses() { + for addr in [ + "ws://127.0.0.1", + "wss://example.com", + "127.0.0.1:4500", + "https://127.0.0.1:4500", + ] { + let err = resolve_remote_addr(addr).expect_err("invalid remote addresses should fail"); + assert!( + err.to_string().contains( + "expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`" + ) + ); + } +} + +#[tokio::test] +async fn default_daemon_auto_connect_skips_missing_socket() -> color_eyre::Result<()> { + let codex_home = TempDir::new()?; + assert!( + maybe_probe_default_daemon_socket(codex_home.path()) + .await + .is_none() + ); + Ok(()) +} + +#[tokio::test] +async fn default_daemon_auto_connect_probes_socket_only() -> color_eyre::Result<()> { + let codex_home = TempDir::new()?; + let socket_path = codex_app_server_client::app_server_control_socket_path(codex_home.path())?; + #[cfg(windows)] + { + let parent = socket_path.as_path().parent().expect("socket parent"); + std::fs::create_dir_all(parent)?; + let listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; + assert!( + maybe_probe_default_daemon_socket(codex_home.path()) + .await + .is_none() + ); + drop(listener); + std::fs::remove_dir_all(parent)?; + } + codex_uds::prepare_private_socket_directory( + socket_path.as_path().parent().expect("socket parent"), + ) + .await?; + let _listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; + + let expected = Some(socket_path); + #[cfg(windows)] + let expected = { + // Existing Windows CI may run elevated; an elevated listener must + // never be selected implicitly, even when its directory is private. + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", "([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"]) + .output()?; + assert!(output.status.success()); + match String::from_utf8(output.stdout)?.trim() { + "True" => None, + "False" => expected, + other => panic!("unexpected elevation result: {other}"), + } + }; + assert_eq!( + maybe_probe_default_daemon_socket(codex_home.path()).await, + expected + ); + Ok(()) +} + +#[test] +fn app_server_target_for_launch_uses_local_daemon_for_default_socket() -> color_eyre::Result<()> { + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + let endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: socket_path.clone(), + }; + for explicit in [false, true] { + let target = app_server_target_for_launch( + explicit.then_some(endpoint.clone()), + Some(socket_path.clone()), + /*can_reuse_implicit_local_daemon*/ !explicit, + /*workload_identity_selected*/ false, + /*exec_server_url*/ None, + )?; + assert_eq!( + target, + AppServerTarget::LocalDaemon { + allow_embedded_fallback: !explicit, + endpoint: endpoint.clone(), + } + ); + assert!(!target.uses_remote_workspace()); + assert_eq!(target.thread_params_mode(), ThreadParamsMode::Embedded); + } + Ok(()) +} + +#[test] +fn app_server_target_for_launch_preserves_executor_selection() -> color_eyre::Result<()> { + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + for executor in ["none", "ws://127.0.0.1:4501"] { + assert_eq!( + app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(socket_path.clone()), + /*can_reuse_implicit_local_daemon*/ true, + /*workload_identity_selected*/ false, + Some(std::ffi::OsStr::new(executor)), + )?, + AppServerTarget::Embedded, + ); + } + Ok(()) +} + +#[test] +fn app_server_target_for_launch_prefers_explicit_remote_endpoint() -> color_eyre::Result<()> { + let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, + }; + let target = app_server_target_for_launch( + Some(explicit_endpoint.clone()), + Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?), + /*can_reuse_implicit_local_daemon*/ false, + /*workload_identity_selected*/ false, + Some(std::ffi::OsStr::new("none")), + )?; + + assert_eq!( + target, + AppServerTarget::Remote { + endpoint: explicit_endpoint, + } + ); + assert!(target.uses_remote_workspace()); + assert_eq!(target.thread_params_mode(), ThreadParamsMode::Remote); + Ok(()) +} + +#[test] +fn app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable() +-> color_eyre::Result<()> { + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + let target = app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(socket_path), + /*can_reuse_implicit_local_daemon*/ false, + /*workload_identity_selected*/ false, + /*exec_server_url*/ None, + )?; + + assert_eq!(target, AppServerTarget::Embedded); + Ok(()) +} + +#[test] +fn workload_identity_requires_an_embedded_app_server() -> color_eyre::Result<()> { + let default_socket = AbsolutePathBuf::relative_to_current_dir("default.sock")?; + assert_eq!( + app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(default_socket), + /*can_reuse_implicit_local_daemon*/ true, + /*workload_identity_selected*/ true, + /*exec_server_url*/ None, + )?, + AppServerTarget::Embedded + ); + + let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("explicit.sock")?, + }; + let error = app_server_target_for_launch( + Some(explicit_endpoint), + /*default_daemon_socket*/ None, + /*can_reuse_implicit_local_daemon*/ false, + /*workload_identity_selected*/ true, + /*exec_server_url*/ None, + ) + .expect_err("remote hosts must own workload identity"); + assert_eq!( + error.to_string(), + "workload identity must be configured on the remote app-server host" + ); + Ok(()) +} + +#[test] +fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { + let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + + assert!(should_load_configured_environments( + &LoaderOverrides::default(), + &target, + )); + Ok(()) +} + +#[tokio::test] +async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions() +-> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let cwd = temp_dir.path().join("project"); + + let params = latest_session_lookup_params( + /*uses_remote_filesystem*/ false, + /*model_provider*/ None, + Some(cwd.as_path()), + /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, + ); + + assert_eq!(params.model_providers, None); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) + ); + assert!(params.use_state_db_only); + + let scan_params = latest_session_lookup_params( + /*uses_remote_filesystem*/ false, + /*model_provider*/ None, + Some(cwd.as_path()), + /*include_non_interactive*/ false, + LatestSessionLookupMode::ScanAndRepair, + ); + assert!(!scan_params.use_state_db_only); + Ok(()) +} + +#[tokio::test] +async fn latest_session_lookup_params_honor_explicit_provider() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let cwd = temp_dir.path().join("project"); + let params = latest_session_lookup_params( + /*uses_remote_filesystem*/ false, + Some("selected-provider".to_string()), + Some(cwd.as_path()), + /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, + ); + + assert_eq!( + params.model_providers, + Some(vec!["selected-provider".to_string()]) + ); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) + ); + Ok(()) +} + +#[tokio::test] +async fn latest_session_lookup_params_omit_local_filters_for_remote_sessions() -> std::io::Result<()> +{ + let params = latest_session_lookup_params( + /*uses_remote_filesystem*/ true, + /*model_provider*/ None, + /*cwd_filter*/ None, + /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, + ); + + assert_eq!(params.model_providers, None); + assert_eq!(params.cwd, None); + Ok(()) +} + +#[tokio::test] +async fn latest_session_lookup_params_can_include_non_interactive_sources() -> std::io::Result<()> { + let params = latest_session_lookup_params( + /*uses_remote_filesystem*/ true, + /*model_provider*/ None, + /*cwd_filter*/ None, + /*include_non_interactive*/ true, + LatestSessionLookupMode::StateDbOnly, + ); + + assert_eq!( + params.source_kinds, + Some(vec![ + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ThreadSourceKind::Exec, + ThreadSourceKind::AppServer, + ]) + ); + Ok(()) +} + +#[tokio::test] +async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessions() +-> std::io::Result<()> { + let cwd = Path::new("repo/on/server"); + + let params = latest_session_lookup_params( + /*uses_remote_filesystem*/ true, + /*model_provider*/ None, + Some(cwd), + /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, + ); + + assert_eq!(params.model_providers, None); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(String::from("repo/on/server"))) + ); + Ok(()) +} + +#[tokio::test] +async fn latest_session_cwd_filter_respects_scope_options() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let remote_cwd = Path::new("repo/on/server"); + + let local_filter = latest_session_cwd_filter( + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ false, + ); + let show_all_filter = latest_session_cwd_filter( + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ true, + ); + let remote_filter = latest_session_cwd_filter( + /*uses_remote_workspace*/ true, + Some(remote_cwd), + &config, + /*show_all*/ false, + ); + + assert_eq!(local_filter, Some(config.cwd.as_path())); + assert_eq!(show_all_filter, None); + assert_eq!(remote_filter, Some(remote_cwd)); + Ok(()) +} + +#[tokio::test] +async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let project_cwd = temp_dir.path().join("project"); + let linked_cwd = temp_dir.path().join("linked-project"); + let other_cwd = temp_dir.path().join("other-project"); + let admin = project_cwd.join(".git/worktrees/linked"); + std::fs::create_dir_all(&admin)?; + std::fs::create_dir_all(&linked_cwd)?; + std::fs::create_dir_all(&other_cwd)?; + std::fs::write(project_cwd.join(".git/HEAD"), "ref: refs/heads/main\n")?; + std::fs::write(admin.join("commondir"), "../..\n")?; + std::fs::write( + admin.join("gitdir"), + linked_cwd.join(".git").display().to_string(), + )?; + std::fs::write( + linked_cwd.join(".git"), + format!("gitdir: {}", admin.display()), + )?; + + let mut config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(project_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + config + .features + .enable(codex_features::Feature::LocalThreadStoreCompression)?; + let model_provider = config.model_provider_id.as_str(); + let project_thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "older project session", + model_provider, + &project_cwd, + )?; + let linked_thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T11-00-00", + "2025-01-02T11:00:00Z", + "newer linked-worktree session", + model_provider, + &linked_cwd, + )?; + let other_thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "newer other project session", + model_provider, + &other_cwd, + )?; + let rollout_paths = [ + ("2025-01-02T10-00-00", project_thread_id), + ("2025-01-02T11-00-00", linked_thread_id), + ("2025-01-02T12-00-00", other_thread_id), + ] + .map(|(timestamp, thread_id)| { + app_test_support::rollout_path(temp_dir.path(), timestamp, &thread_id.to_string()) + }); + + let mut app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config.clone()).await?, + ), + ThreadParamsMode::Embedded, + ); + // Exercise the logical .jsonl paths returned by thread/list after startup compression. + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { + while rollout_paths + .iter() + .any(|path| path.exists() || !path.with_extension("jsonl.zst").exists()) + { + tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 10)).await; + } + }) + .await?; + let filter_cwd = latest_session_cwd_filter( + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ false, + ); + let scoped_target = lookup_latest_session_target_with_app_server( + /*uses_remote_filesystem*/ false, + &mut app_server, + &config, + filter_cwd, + /*include_non_interactive*/ false, + ) + .await? + .expect("expected project-scoped fork --last target"); + let show_all_filter_cwd = latest_session_cwd_filter( + /*uses_remote_workspace*/ false, /*remote_cwd_override*/ None, &config, + /*show_all*/ true, + ); + let show_all_target = lookup_latest_session_target_with_app_server( + /*uses_remote_filesystem*/ false, + &mut app_server, + &config, + show_all_filter_cwd, + /*include_non_interactive*/ false, + ) + .await? + .expect("expected global fork --last target"); + app_server.shutdown().await?; + + assert_eq!(scoped_target.thread_id, linked_thread_id); + assert_eq!(show_all_target.thread_id, other_thread_id); + Ok(()) +} + +#[tokio::test] +async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db() +-> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let project_cwd = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(project_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let mut app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config.clone()).await?, + ), + ThreadParamsMode::Embedded, + ); + + // Simulate a legacy writer creating a rollout after the state DB backfill completed. + let thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "legacy writer session", + config.model_provider_id.as_str(), + &project_cwd, + )?; + + let target = lookup_latest_session_target_with_app_server( + /*uses_remote_filesystem*/ false, + &mut app_server, + &config, + Some(project_cwd.as_path()), + /*include_non_interactive*/ false, + ) + .await? + .expect("expected scan-and-repair fallback to find the rollout"); + app_server.shutdown().await?; + + assert_eq!(target.thread_id, thread_id); + Ok(()) +} + +#[tokio::test] +async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> { + let remote_only_cwd = if cfg!(windows) { + Path::new(r"C:\definitely\not\local\to\this\test") + } else { + Path::new("/definitely/not/local/to/this/test") + }; + let target = AppServerTarget::Remote { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let environment_manager = EnvironmentManager::default_for_tests(); + + let config_cwd = config_cwd_for_app_server_target( + Some(remote_only_cwd), + &target, + environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()), + )?; + + assert_eq!(config_cwd, None); + Ok(()) +} + +#[tokio::test] +async fn config_cwd_for_app_server_target_canonicalizes_embedded_cli_cwd() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let target = AppServerTarget::Embedded; + let environment_manager = EnvironmentManager::default_for_tests(); + + let config_cwd = config_cwd_for_app_server_target( + Some(temp_dir.path()), + &target, + environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()), + )?; + + assert_eq!( + config_cwd, + Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( + temp_dir.path() + )?)?) + ); + Ok(()) +} + +#[tokio::test] +async fn config_cwd_for_app_server_target_canonicalizes_local_daemon_cli_cwd() -> std::io::Result<()> +{ + let temp_dir = TempDir::new()?; + let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let environment_manager = EnvironmentManager::default_for_tests(); + + let config_cwd = config_cwd_for_app_server_target( + Some(temp_dir.path()), + &target, + environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()), + )?; + + assert_eq!( + config_cwd, + Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( + temp_dir.path() + )?)?) + ); + Ok(()) +} + +#[tokio::test] +async fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() +-> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let missing = temp_dir.path().join("missing"); + let target = AppServerTarget::Embedded; + let environment_manager = EnvironmentManager::default_for_tests(); + + let err = config_cwd_for_app_server_target( + Some(&missing), + &target, + environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()), + ) + .expect_err("missing embedded cwd should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + Ok(()) +} + +#[tokio::test] +async fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() -> std::io::Result<()> +{ + let remote_only_cwd = if cfg!(windows) { + Path::new(r"C:\definitely\not\local\to\this\test") + } else { + Path::new("/definitely/not/local/to/this/test") + }; + let target = AppServerTarget::Embedded; + let environment_manager = EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + Some(ExecServerRuntimeOptions::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + )?), + ) + .await; + + let config_cwd = config_cwd_for_app_server_target( + Some(remote_only_cwd), + &target, + environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()), + )?; + + assert_eq!(config_cwd, None); + assert!(!projectless::has_only_local_environments( + &environment_manager + )); + assert!(projectless::has_only_local_environments( + &EnvironmentManager::default_for_tests() + )); + assert!(uses_remote_workspace_or_environment( + &target, + &environment_manager + )); + let local_daemon = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + assert!(uses_remote_workspace_or_environment( + &local_daemon, + &environment_manager + )); + Ok(()) +} + +#[tokio::test] +async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let app_server = start_test_embedded_app_server(config).await?; + let response: ThreadStartResponse = app_server + .request_typed(ClientRequest::ThreadStart { + request_id: RequestId::Integer(1), + params: ThreadStartParams { + ephemeral: Some(true), + ..ThreadStartParams::default() + }, + }) + .await + .expect("thread/start should succeed"); + assert!(!response.thread.id.is_empty()); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn resume_picker_loads_complete_paginated_and_legacy_transcripts() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + config.terminal_resize_reflow.max_rows = + crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(2); + let mut app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::InProcess(start_test_embedded_app_server(config.clone()).await?), + ThreadParamsMode::Embedded, + ); + let filename_ts = "2025-01-05T12-00-00"; + let rollout_line = |ordinal: usize, payload: serde_json::Value| { + serde_json::json!({ + "timestamp": "2025-01-05T12:00:00Z", + "type": "event_msg", + "payload": payload, + "ordinal": ordinal, + }) + }; + for (history_mode, create_rollout) in [ + app_test_support::create_fake_rollout, + app_test_support::create_fake_paginated_rollout, + ] + .into_iter() + .enumerate() + { + let thread_id = create_rollout( + temp_dir.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "message 0", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create session rollout"); + let path = app_test_support::rollout_path(temp_dir.path(), filename_ts, &thread_id); + let mut contents = std::fs::read_to_string(&path)?; + let started = rollout_line( + /*ordinal*/ 3, + serde_json::json!({ "type": "task_started", "turn_id": "history-turn", "model_context_window": null }), + ); + contents.push_str(&format!("{started}\n")); + for index in 0..=100 { + let message = format!("message {index}"); + let payload = if history_mode == 1 { + serde_json::json!({ + "type": "item_completed", + "thread_id": thread_id, + "turn_id": "history-turn", + "item": { "type": "UserMessage", "id": format!("user-{index}"), + "content": [{ "type": "text", "text": message }] }, + }) + } else { + serde_json::json!({ "type": "user_message", "message": message }) + }; + let item = rollout_line(index + 4, payload); + contents.push_str(&format!("{item}\n")); + } + if history_mode == 1 { + for index in 0..125 { + let item = rollout_line( + index + 105, + serde_json::json!({ + "type": "item_completed", + "thread_id": thread_id, + "turn_id": "history-turn", + "item": { "type": "Reasoning", "id": format!("hidden-{index}"), + "summary_text": [], "raw_content": [] }, + }), + ); + contents.push_str(&format!("{item}\n")); + } + } + std::fs::write(path, contents)?; + let thread_id = ThreadId::from_string(&thread_id)?; + let started = app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config.clone(), + thread_id, + app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + if history_mode == 1 { + assert!( + started + .turns + .iter() + .flat_map(|turn| &turn.items) + .any(|item| { + matches!( + item, + codex_app_server_protocol::ThreadItem::UserMessage { .. } + ) + }) + ); + let preview = crate::resume_picker::load_transcript_preview( + &mut app_server, + thread_id, + /*config*/ None, + ) + .await?; + assert!(!preview.is_empty()); + } + let cells = crate::thread_transcript::load_session_transcript( + &mut app_server, + thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + /*config*/ None, + ) + .await?; + assert!(cells.len() > 100); + } + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn embedded_app_server_start_failure_is_returned() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let result = start_embedded_app_server_with( + Arg0DispatchPaths::default(), + config, + Vec::new(), + LoaderOverrides::default(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + /*state_db*/ None, + Arc::new(EnvironmentManager::default_for_tests()), + Default::default(), + |_args| async { Err(std::io::Error::other("boom")) }, + ) + .await; + let err = match result { + Ok(_) => panic!("startup failure should be returned"), + Err(err) => err, + }; + + assert!( + err.to_string() + .contains("failed to start embedded app server"), + "error should preserve the embedded app server startup context" + ); + Ok(()) +} + +#[tokio::test] +async fn embedded_state_db_failure_is_typed_for_cli_recovery() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + let occupied_sqlite_home = temp_dir.path().join("sqlite-home"); + std::fs::write(&occupied_sqlite_home, "occupied")?; + let sqlite = codex_state::SqliteConfig::new_for_testing(occupied_sqlite_home.as_path().abs()); + config.sqlite = sqlite.clone(); + + let err = match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { + Ok(_) => panic!("embedded startup should surface state db init failures"), + Err(err) => err, + }; + let startup_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .expect("state db startup failure should retain its typed context"); + + assert_eq!( + startup_error.state_db_path(), + sqlite.state_db_path().as_path() + ); + assert!( + startup_error + .detail() + .contains("failed to initialize state runtime"), + "startup error should preserve the underlying state db failure" + ); + Ok(()) +} + +#[tokio::test] +async fn embedded_state_db_corruption_preserves_failed_database_for_cli_recovery() +-> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + let sqlite_home = temp_dir.path().join("sqlite-home"); + std::fs::create_dir_all(&sqlite_home)?; + let sqlite = codex_state::SqliteConfig::new_for_testing(sqlite_home.as_path().abs()); + let logs_db_path = sqlite.logs_db_path(); + std::fs::write(&logs_db_path, "not a sqlite database")?; + config.sqlite = sqlite; + + let err = match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { + Ok(_) => panic!("embedded startup should surface state db init failures"), + Err(err) => err, + }; + let startup_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .expect("state db startup failure should retain its typed context"); + + assert_eq!(startup_error.database_path(), logs_db_path.as_path()); + assert!( + startup_error.is_corruption(), + "startup error should preserve the SQLite corruption cause, got: {}", + startup_error.detail() + ); + Ok(()) +} + +#[tokio::test] +async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().to_path_buf(); + let trusted = temp_dir.path().join("trusted"); + let untrusted = temp_dir.path().join("untrusted"); + std::fs::create_dir_all(&trusted)?; + std::fs::create_dir_all(&untrusted)?; + + // TOML keys need escaped backslashes on Windows paths. + let trusted_display = trusted.display().to_string().replace('\\', "\\\\"); + let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\"); + let config_toml = format!( + r#"[projects."{trusted_display}"] +trust_level = "trusted" + +[projects."{untrusted_display}"] +trust_level = "untrusted" +"# + ); + std::fs::write(temp_dir.path().join("config.toml"), config_toml)?; + + let trusted_overrides = ConfigOverrides { + cwd: Some(trusted.clone()), + ..Default::default() + }; + let trusted_config = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .codex_home(codex_home.clone()) + .harness_overrides(trusted_overrides.clone()) + .build() + .await?; + assert_eq!( + AskForApproval::from(trusted_config.permissions.approval_policy.value()), + AskForApproval::OnRequest + ); + + let untrusted_overrides = ConfigOverrides { + cwd: Some(untrusted), + ..trusted_overrides + }; + let untrusted_config = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .codex_home(codex_home) + .harness_overrides(untrusted_overrides) + .build() + .await?; + assert_eq!( + AskForApproval::from(untrusted_config.permissions.approval_policy.value()), + AskForApproval::UnlessTrusted + ); + Ok(()) +} + +/// Regression: theme must be configured from the *final* config. +/// +/// `run_ratatui_app` can reload config during onboarding and again +/// during session resume/fork. The syntax theme override (stored in +/// a `OnceLock`) must use the final config's `tui_theme`, not the +/// initial one — otherwise users resuming a thread in a project with +/// a different theme get the wrong highlighting. +/// +/// We verify the invariant indirectly: `validate_theme_name` (the +/// pure validation core of `set_theme_override`) must be called with +/// the *final* config's theme, and its warning must land in the +/// final config's `startup_warnings`. +#[tokio::test] +async fn theme_warning_uses_final_config() -> std::io::Result<()> { + use crate::render::highlight::validate_theme_name; + + let temp_dir = TempDir::new()?; + + // initial_config has a valid theme — no warning. + let initial_config = build_config(&temp_dir).await?; + assert!(initial_config.tui_theme.is_none()); + + // Simulate resume/fork reload: the final config has an invalid theme. + let mut config = build_config(&temp_dir).await?; + config.tui_theme = Some("bogus-theme".into()); + + // Theme override must use the final config (not initial_config). + // This mirrors the real call site in run_ratatui_app. + if let Some(w) = validate_theme_name(config.tui_theme.as_deref(), Some(temp_dir.path())) { + config.startup_warnings.push(w); + } + + assert_eq!( + config.startup_warnings.len(), + 1, + "warning from final config's invalid theme should be present" + ); + assert!( + config.startup_warnings[0].contains("bogus-theme"), + "warning should reference the final config's theme name" + ); + Ok(()) +} + +#[path = "daemon_startup_tests.rs"] +mod daemon_startup_tests; +#[path = "test_backend.rs"] +pub(crate) mod test_backend; +#[path = "test_support.rs"] +pub(crate) mod test_support; diff --git a/codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap b/codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap similarity index 100% rename from codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap rename to codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap diff --git a/codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_feature_mismatch_error.snap b/codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_feature_mismatch_error.snap similarity index 100% rename from codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_feature_mismatch_error.snap rename to codex-rs/tui/src/snapshots/codex_tui__tests__daemon_startup_tests__daemon_feature_mismatch_error.snap diff --git a/codex-rs/utils/cache/src/initialization_tests.rs b/codex-rs/utils/cache/src/initialization_tests.rs index 23040f704e..e91bad36ad 100644 --- a/codex-rs/utils/cache/src/initialization_tests.rs +++ b/codex-rs/utils/cache/src/initialization_tests.rs @@ -1,6 +1,6 @@ //! Checks that initialization releases the global lock and survives LRU eviction. -use super::BlockingLruCache; +use crate::BlockingLruCache; use std::num::NonZeroUsize; use std::sync::mpsc; use std::time::Duration; diff --git a/codex-rs/utils/cache/src/lib.rs b/codex-rs/utils/cache/src/lib.rs index 5375a0f4d4..bad35f9c31 100644 --- a/codex-rs/utils/cache/src/lib.rs +++ b/codex-rs/utils/cache/src/lib.rs @@ -163,56 +163,5 @@ pub fn sha1_digest(bytes: &[u8]) -> [u8; 20] { } #[cfg(test)] -#[path = "initialization_tests.rs"] -mod initialization_tests; - -#[cfg(test)] -mod tests { - use super::BlockingLruCache; - use std::num::NonZeroUsize; - - #[tokio::test(flavor = "multi_thread")] - async fn stores_and_retrieves_values() { - let cache = BlockingLruCache::new(NonZeroUsize::new(2).expect("capacity")); - - assert!(cache.get(&"first").is_none()); - cache.insert("first", /*value*/ 1); - assert_eq!(cache.get(&"first"), Some(1)); - } - - #[tokio::test(flavor = "multi_thread")] - async fn evicts_least_recently_used() { - let cache = BlockingLruCache::new(NonZeroUsize::new(2).expect("capacity")); - cache.insert("a", /*value*/ 1); - cache.insert("b", /*value*/ 2); - assert_eq!(cache.get(&"a"), Some(1)); - - cache.insert("c", /*value*/ 3); - - assert!(cache.get(&"b").is_none()); - assert_eq!(cache.get(&"a"), Some(1)); - assert_eq!(cache.get(&"c"), Some(3)); - } - - #[test] - fn disabled_without_runtime() { - let cache = BlockingLruCache::new(NonZeroUsize::new(2).expect("capacity")); - cache.insert("first", /*value*/ 1); - assert!(cache.get(&"first").is_none()); - - assert_eq!(cache.get_or_insert_with("first", || 2), 2); - assert!(cache.get(&"first").is_none()); - - assert!(cache.remove(&"first").is_none()); - cache.clear(); - - let result = cache.with_mut(|inner| { - inner.put("tmp", 3); - inner.get(&"tmp").cloned() - }); - assert_eq!(result, Some(3)); - assert!(cache.get(&"tmp").is_none()); - - assert!(cache.blocking_lock().is_none()); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/utils/cache/src/lib_tests.rs b/codex-rs/utils/cache/src/lib_tests.rs new file mode 100644 index 0000000000..4ca1865419 --- /dev/null +++ b/codex-rs/utils/cache/src/lib_tests.rs @@ -0,0 +1,54 @@ +#![cfg(test)] + +//! Unit tests for the crate and its related test suites. + +use super::BlockingLruCache; +use std::num::NonZeroUsize; + +#[tokio::test(flavor = "multi_thread")] +async fn stores_and_retrieves_values() { + let cache = BlockingLruCache::new(NonZeroUsize::new(2).expect("capacity")); + + assert!(cache.get(&"first").is_none()); + cache.insert("first", /*value*/ 1); + assert_eq!(cache.get(&"first"), Some(1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn evicts_least_recently_used() { + let cache = BlockingLruCache::new(NonZeroUsize::new(2).expect("capacity")); + cache.insert("a", /*value*/ 1); + cache.insert("b", /*value*/ 2); + assert_eq!(cache.get(&"a"), Some(1)); + + cache.insert("c", /*value*/ 3); + + assert!(cache.get(&"b").is_none()); + assert_eq!(cache.get(&"a"), Some(1)); + assert_eq!(cache.get(&"c"), Some(3)); +} + +#[test] +fn disabled_without_runtime() { + let cache = BlockingLruCache::new(NonZeroUsize::new(2).expect("capacity")); + cache.insert("first", /*value*/ 1); + assert!(cache.get(&"first").is_none()); + + assert_eq!(cache.get_or_insert_with("first", || 2), 2); + assert!(cache.get(&"first").is_none()); + + assert!(cache.remove(&"first").is_none()); + cache.clear(); + + let result = cache.with_mut(|inner| { + inner.put("tmp", 3); + inner.get(&"tmp").cloned() + }); + assert_eq!(result, Some(3)); + assert!(cache.get(&"tmp").is_none()); + + assert!(cache.blocking_lock().is_none()); +} + +#[path = "initialization_tests.rs"] +mod initialization_tests; diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index c2a0629156..8b0070fde2 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -65,6 +65,6 @@ mod spawn_helper; mod spawn_helper_main; #[cfg(target_os = "linux")] pub use spawn_helper::init_spawn_helper; + #[cfg(all(test, target_os = "linux"))] -#[path = "spawn_helper_tests.rs"] -mod spawn_helper_tests; +use tests::spawn_helper_tests; diff --git a/codex-rs/utils/pty/src/spawn_helper_tests.rs b/codex-rs/utils/pty/src/spawn_helper_tests.rs index 49a7738eeb..e858831ae8 100644 --- a/codex-rs/utils/pty/src/spawn_helper_tests.rs +++ b/codex-rs/utils/pty/src/spawn_helper_tests.rs @@ -619,7 +619,7 @@ fn helper_dispatch_before_rust_initializes_argv() -> anyhow::Result<()> { let output = std::process::Command::new(std::env::current_exe()?) .args([ "--exact", - "spawn_helper_tests::helper_preserves_cwd_env_arg0_and_streams", + "tests::spawn_helper_tests::helper_preserves_cwd_env_arg0_and_streams", "--nocapture", ]) .env("CODEX_TEST_EARLY_HELPER_ARGV", "1") @@ -659,7 +659,7 @@ async fn helper_control_socket_survives_closed_stdio() -> anyhow::Result<()> { let output = std::process::Command::new(std::env::current_exe()?) .args([ "--exact", - "spawn_helper_tests::helper_control_socket_survives_closed_stdio", + "tests::spawn_helper_tests::helper_control_socket_survives_closed_stdio", "--nocapture", ]) .env("CODEX_TEST_HELPER_CLOSED_STDIO", "1") @@ -709,7 +709,7 @@ async fn helper_descriptor_pressure_falls_back_to_direct_spawn() -> anyhow::Resu let output = std::process::Command::new(std::env::current_exe()?) .args([ "--exact", - "spawn_helper_tests::helper_descriptor_pressure_falls_back_to_direct_spawn", + "tests::spawn_helper_tests::helper_descriptor_pressure_falls_back_to_direct_spawn", "--nocapture", ]) .env("CODEX_TEST_HELPER_FD_PRESSURE", "1") @@ -782,7 +782,7 @@ async fn parent_loader_environment_does_not_disable_helper_dispatch() -> anyhow: let output = std::process::Command::new(std::env::current_exe()?) .args([ "--exact", - "spawn_helper_tests::parent_loader_environment_does_not_disable_helper_dispatch", + "tests::spawn_helper_tests::parent_loader_environment_does_not_disable_helper_dispatch", "--nocapture", ]) .env("CODEX_TEST_PARENT_LOADER_ENV", "1") diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index 3bcd3a947c..20cf4d5b26 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -1657,3 +1657,7 @@ async fn pipe_spawn_no_stdin_can_preserve_inherited_fds() -> anyhow::Result<()> Ok(()) } + +#[cfg(target_os = "linux")] +#[path = "spawn_helper_tests.rs"] +pub(crate) mod spawn_helper_tests; diff --git a/codex-rs/websocket-client/src/lib.rs b/codex-rs/websocket-client/src/lib.rs index 28dbe21484..98c92114ef 100644 --- a/codex-rs/websocket-client/src/lib.rs +++ b/codex-rs/websocket-client/src/lib.rs @@ -365,11 +365,3 @@ impl AsyncIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {} #[cfg(test)] #[path = "lib_tests.rs"] mod tests; - -#[cfg(test)] -#[path = "cookie_tests.rs"] -mod cookie_tests; - -#[cfg(test)] -#[path = "network_policy_tests.rs"] -mod network_policy_tests; diff --git a/codex-rs/websocket-client/src/lib_tests.rs b/codex-rs/websocket-client/src/lib_tests.rs index c519671adb..13abe22490 100644 --- a/codex-rs/websocket-client/src/lib_tests.rs +++ b/codex-rs/websocket-client/src/lib_tests.rs @@ -32,3 +32,8 @@ fn rejects_non_loopback_websocket_destinations() { )); } } + +#[path = "cookie_tests.rs"] +mod cookie_tests; +#[path = "network_policy_tests.rs"] +mod network_policy_tests; diff --git a/codex-rs/websocket-client/src/network_policy_tests.rs b/codex-rs/websocket-client/src/network_policy_tests.rs index 8be65d2772..05b56ac834 100644 --- a/codex-rs/websocket-client/src/network_policy_tests.rs +++ b/codex-rs/websocket-client/src/network_policy_tests.rs @@ -1,6 +1,6 @@ //! Split readers keep their revocation wakeup and report denial even when writers fail first. -use super::*; +use crate::*; use codex_http_client::DestinationPolicy; use codex_http_client::NetworkPolicyController; use codex_http_client::OutboundProxyPolicy::ReqwestDefault;