From c227654800307e23f440efaaa86131fabbd9388c Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Thu, 22 Oct 2026 19:28:13 +0000 Subject: [PATCH] Consolidate Rust unit tests under dedicated test modules (#56247) ## What changed Move inline crate tests into `lib_tests.rs` and nest related test suites under each crate's `tests` module. Update imports, subprocess test filters, logging targets, and snapshot names to match the new module paths. Keep test helpers accessible where needed and preserve the schema writer's exact test name used by release tooling. GitOrigin-RevId: de55eaae66dce4941abb33dd0b2f170093daa8f9 --- codex-rs/app-server-protocol/src/lib.rs | 21 +- codex-rs/app-server-protocol/src/lib_tests.rs | 8 + codex-rs/app-server/src/lib.rs | 86 +- codex-rs/app-server/src/lib_tests.rs | 83 + .../app-server/src/stderr_logging_tests.rs | 2 +- codex-rs/aws-auth/src/lib.rs | 222 +-- codex-rs/aws-auth/src/lib_tests.rs | 219 +++ codex-rs/aws-auth/src/network_policy_tests.rs | 14 +- codex-rs/ext/mcp/src/lib.rs | 7 - codex-rs/ext/mcp/src/lib_tests.rs | 5 + codex-rs/ext/mcp/src/stream_manager_tests.rs | 6 +- codex-rs/feedback/src/lib.rs | 840 +------- codex-rs/feedback/src/lib_tests.rs | 833 ++++++++ codex-rs/feedback/src/metadata_tests.rs | 2 +- .../feedback/src/rollout_attachment_tests.rs | 2 +- codex-rs/feedback/src/save_rollout_tests.rs | 2 +- codex-rs/file-search/src/lib.rs | 572 +----- codex-rs/file-search/src/lib_tests.rs | 570 ++++++ codex-rs/file-search/src/matcher_tests.rs | 2 +- codex-rs/install-context/src/bundle_tests.rs | 2 +- codex-rs/install-context/src/lib.rs | 521 +---- codex-rs/install-context/src/lib_tests.rs | 518 +++++ codex-rs/mermaid/src/families_tests.rs | 30 +- codex-rs/mermaid/src/lib.rs | 4 - ...x_mermaid__tests__families_tests__LR.snap} | 0 ...ermaid__tests__families_tests__class.snap} | 0 ...x_mermaid__tests__families_tests__er.snap} | 0 ...aid__tests__families_tests__sequence.snap} | 0 ...ermaid__tests__families_tests__state.snap} | 0 codex-rs/mermaid/src/tests.rs | 3 + codex-rs/message-history/src/batch_tests.rs | 2 +- codex-rs/message-history/src/lib.rs | 3 - codex-rs/message-history/src/tests.rs | 3 + codex-rs/tui/src/daemon_startup_tests.rs | 2 +- codex-rs/tui/src/lib.rs | 1738 +---------------- codex-rs/tui/src/lib_tests.rs | 1726 ++++++++++++++++ ...rtup_tests__daemon_exclusion_warning.snap} | 0 ...tests__daemon_feature_mismatch_error.snap} | 0 .../utils/cache/src/initialization_tests.rs | 2 +- codex-rs/utils/cache/src/lib.rs | 55 +- codex-rs/utils/cache/src/lib_tests.rs | 54 + codex-rs/utils/pty/src/lib.rs | 4 +- codex-rs/utils/pty/src/spawn_helper_tests.rs | 8 +- codex-rs/utils/pty/src/tests.rs | 4 + codex-rs/websocket-client/src/lib.rs | 8 - codex-rs/websocket-client/src/lib_tests.rs | 5 + .../src/network_policy_tests.rs | 2 +- 47 files changed, 4100 insertions(+), 4090 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/lib_tests.rs create mode 100644 codex-rs/app-server/src/lib_tests.rs create mode 100644 codex-rs/aws-auth/src/lib_tests.rs create mode 100644 codex-rs/feedback/src/lib_tests.rs create mode 100644 codex-rs/file-search/src/lib_tests.rs create mode 100644 codex-rs/install-context/src/lib_tests.rs rename codex-rs/mermaid/src/snapshots/{codex_mermaid__families_tests__LR.snap => codex_mermaid__tests__families_tests__LR.snap} (100%) rename codex-rs/mermaid/src/snapshots/{codex_mermaid__families_tests__class.snap => codex_mermaid__tests__families_tests__class.snap} (100%) rename codex-rs/mermaid/src/snapshots/{codex_mermaid__families_tests__er.snap => codex_mermaid__tests__families_tests__er.snap} (100%) rename codex-rs/mermaid/src/snapshots/{codex_mermaid__families_tests__sequence.snap => codex_mermaid__tests__families_tests__sequence.snap} (100%) rename codex-rs/mermaid/src/snapshots/{codex_mermaid__families_tests__state.snap => codex_mermaid__tests__families_tests__state.snap} (100%) create mode 100644 codex-rs/tui/src/lib_tests.rs rename codex-rs/tui/src/snapshots/{codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap => codex_tui__tests__daemon_startup_tests__daemon_exclusion_warning.snap} (100%) rename codex-rs/tui/src/snapshots/{codex_tui__daemon_startup_tests__daemon_feature_mismatch_error.snap => codex_tui__tests__daemon_startup_tests__daemon_feature_mismatch_error.snap} (100%) create mode 100644 codex-rs/utils/cache/src/lib_tests.rs 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;