use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; use app_test_support::start_analytics_events_server; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> { let codex_home = TempDir::new()?; write_installed_plugin(&codex_home, "debug", "sample-plugin")?; std::fs::write( codex_home.path().join("config.toml"), r#"[features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let params = PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), force_remote_sync: false, }; let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); assert!( !codex_home .path() .join("plugins/cache/debug/sample-plugin") .exists() ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); let request_id = mcp.send_plugin_uninstall_request(params).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); Ok(()) } #[tokio::test] async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> { let server = MockServer::start().await; let codex_home = TempDir::new()?; write_installed_plugin(&codex_home, "debug", "sample-plugin")?; std::fs::write( codex_home.path().join("config.toml"), format!( r#"chatgpt_base_url = "{}/backend-api/" [features] plugins = true [plugins."sample-plugin@debug"] enabled = true "#, server.uri() ), )?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; Mock::given(method("POST")) .and(path("/backend-api/plugins/sample-plugin@debug/uninstall")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( ResponseTemplate::new(200) .set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#), ) .expect(1) .mount(&server) .await; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), force_remote_sync: true, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); assert!( !codex_home .path() .join("plugins/cache/debug/sample-plugin") .exists() ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#)); Ok(()) } #[tokio::test] async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; let codex_home = TempDir::new()?; write_installed_plugin(&codex_home, "debug", "sample-plugin")?; std::fs::write( codex_home.path().join("config.toml"), format!( "chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n", analytics_server.uri() ), )?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_plugin_uninstall_request(PluginUninstallParams { plugin_id: "sample-plugin@debug".to_string(), force_remote_sync: false, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let response: PluginUninstallResponse = to_response(response)?; assert_eq!(response, PluginUninstallResponse {}); let payload = timeout(DEFAULT_TIMEOUT, async { loop { let Some(requests) = analytics_server.received_requests().await else { tokio::time::sleep(Duration::from_millis(25)).await; continue; }; if let Some(request) = requests.iter().find(|request| { request.method == "POST" && request.url.path() == "/codex/analytics-events/events" }) { break request.body.clone(); } tokio::time::sleep(Duration::from_millis(25)).await; } }) .await?; let payload: serde_json::Value = serde_json::from_slice(&payload).expect("analytics payload"); assert_eq!( payload, json!({ "events": [{ "event_type": "codex_plugin_uninstalled", "event_params": { "plugin_id": "sample-plugin@debug", "plugin_name": "sample-plugin", "marketplace_name": "debug", "has_skills": false, "mcp_server_count": 0, "connector_ids": [], "product_client_id": DEFAULT_CLIENT_NAME, } }] }) ); Ok(()) } fn write_installed_plugin( codex_home: &TempDir, marketplace_name: &str, plugin_name: &str, ) -> Result<()> { let plugin_root = codex_home .path() .join("plugins/cache") .join(marketplace_name) .join(plugin_name) .join("local/.codex-plugin"); std::fs::create_dir_all(&plugin_root)?; std::fs::write( plugin_root.join("plugin.json"), format!(r#"{{"name":"{plugin_name}"}}"#), )?; Ok(()) }