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
This commit is contained in:
Dmitriy Kovalenko
2026-10-22 19:36:47 +00:00
committed by copyberry
parent 22db5847c8
commit c227654800
47 changed files with 4100 additions and 4090 deletions
+11 -10
View File
@@ -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;
@@ -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;
+2 -84
View File
@@ -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;
+83
View File
@@ -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;
@@ -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(),
+2 -220
View File
@@ -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<AwsAccessKeys, std::io::ErrorKind>);
impl AwsCredentialsProvider for TestCredentialsProvider {
async fn credentials(&self) -> std::io::Result<AwsAccessKeys> {
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;
+219
View File
@@ -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<AwsAccessKeys, std::io::ErrorKind>);
impl AwsCredentialsProvider for TestCredentialsProvider {
async fn credentials(&self) -> std::io::Result<AwsAccessKeys> {
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;
@@ -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<dyn Error>> {
} 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(_))),
-7
View File
@@ -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<Config> for HostedPluginRuntimeExtension {
+5
View File
@@ -54,3 +54,8 @@ async fn hosted_plugin_runtime_forwards_thread_originator() -> Result<(), Box<dy
Ok(())
}
#[path = "event_stream_tests.rs"]
mod event_stream_tests;
#[path = "stream_manager_tests.rs"]
mod stream_manager_tests;
+3 -3
View File
@@ -16,11 +16,11 @@ use serde_json::json;
use std::sync::Arc;
use tokio::time::timeout;
use super::event_stream_tests::Fixture;
use super::event_stream_tests::StaticAuth;
use super::event_stream_tests::WAIT;
use crate::McpEventStreamManager;
use crate::McpEventStreamUpdate;
use crate::event_stream_tests::Fixture;
use crate::event_stream_tests::StaticAuth;
use crate::event_stream_tests::WAIT;
async fn start_subscription(
manager: &McpEventStreamManager,
+2 -838
View File
@@ -409,10 +409,6 @@ pub struct FeedbackAttachmentPath {
pub attachment_filename_override: Option<String>,
}
#[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<Option<FeedbackAttachment>> {
@@ -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<FeedbackAttachment>,
) -> 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::<Vec<_>>();
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::<codex_http_client::RouteAwareRequestError>()
.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::<Result<Vec<_>>>()
.unwrap();
assert_eq!(
attachments_with_diagnostics
.iter()
.map(|attachment| attachment.filename.as_str())
.collect::<Vec<_>>(),
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::<Result<Vec<_>>>()
.unwrap();
assert_eq!(
attachments_without_diagnostics
.iter()
.map(|attachment| attachment.filename.as_str())
.collect::<Vec<_>>(),
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::<Result<Vec<_>>>()
.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<_>>(),
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;
+833
View File
@@ -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<FeedbackAttachment>,
) -> 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::<Vec<_>>();
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::<codex_http_client::RouteAwareRequestError>()
.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::<Result<Vec<_>>>()
.unwrap();
assert_eq!(
attachments_with_diagnostics
.iter()
.map(|attachment| attachment.filename.as_str())
.collect::<Vec<_>>(),
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::<Result<Vec<_>>>()
.unwrap();
assert_eq!(
attachments_without_diagnostics
.iter()
.map(|attachment| attachment.filename.as_str())
.collect::<Vec<_>>(),
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::<Result<Vec<_>>>()
.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<_>>(),
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;
+1 -1
View File
@@ -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;
@@ -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";
+1 -1
View File
@@ -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 {
+2 -570
View File
@@ -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::<char>::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<Vec<FileSearchSnapshot>>,
complete_times: Mutex<Vec<Instant>>,
complete_cv: Condvar,
update_cv: Condvar,
}
impl RecordingReporter {
fn wait_until<T, F>(
&self,
mutex: &Mutex<T>,
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<FileSearchSnapshot> {
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;
+570
View File
@@ -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::<char>::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<Vec<FileSearchSnapshot>>,
complete_times: Mutex<Vec<Instant>>,
complete_cv: Condvar,
update_cv: Condvar,
}
impl RecordingReporter {
fn wait_until<T, F>(
&self,
mutex: &Mutex<T>,
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<FileSearchSnapshot> {
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;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Regression coverage for associating asynchronous matcher results with their query.
use super::*;
use crate::*;
use pretty_assertions::assert_eq;
#[test]
+1 -1
View File
@@ -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;
+2 -519
View File
@@ -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;
+518
View File
@@ -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;
+15 -15
View File
@@ -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(),
-4
View File
@@ -170,7 +170,3 @@ impl Graph {
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
#[cfg(test)]
#[path = "families_tests.rs"]
mod families_tests;
+3
View File
@@ -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;
+1 -1
View File
@@ -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<String>) -> HistoryEntry {
HistoryEntry {
-3
View File
@@ -439,8 +439,5 @@ fn log_identity(_metadata: &std::fs::Metadata) -> Option<u64> {
None
}
#[cfg(test)]
#[path = "batch_tests.rs"]
mod batch_tests;
#[cfg(test)]
mod tests;
+3
View File
@@ -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;
+1 -1
View File
@@ -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;
+5 -1733
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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;
+2 -53
View File
@@ -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;
+54
View File
@@ -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;
+2 -2
View File
@@ -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;
+4 -4
View File
@@ -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")
+4
View File
@@ -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;
-8
View File
@@ -365,11 +365,3 @@ impl<T> 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;
@@ -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;
@@ -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;