use super::*; use crate::test_support::recorded_http_client_urls; use crate::test_support::recording_remote_plugin_service_config; use pretty_assertions::assert_eq; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header_exists; use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; use wiremock::matchers::query_param_is_missing; #[tokio::test] async fn remote_plugin_list_routes_the_complete_query_url() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/list")) .and(header_exists("user-agent")) .and(header_exists("originator")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [], "pagination": {"next_page_token": null}, }))) .expect(1) .mount(&server) .await; let (config, selected_urls) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); get_remote_plugin_list_page( &config, &auth, RemotePluginScope::Global, Some("next page/+"), Some("vertical & special"), ) .await .expect("plugin list request should succeed"); assert_eq!( recorded_http_client_urls(&selected_urls), vec![format!( "{}/backend-api/ps/plugins/list?scope=GLOBAL&limit=200&collection=vertical+%26+special&pageToken=next+page%2F%2B", server.uri() )] ); } #[tokio::test] async fn recommended_plugins_requests_codex_suggestions_endpoint() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/suggested/codex")) .and(query_param("scope", "GLOBAL")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "enabled": true, "plugins": [], }))) .expect(1) .mount(&server) .await; let (config, selected_urls) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let mode = fetch_recommended_plugins(&config, Some(&auth)) .await .expect("recommended plugin request should succeed"); assert_eq!( mode, RecommendedPluginsMode::Endpoint { plugins: Vec::new(), } ); assert_eq!( recorded_http_client_urls(&selected_urls), vec![format!( "{}/backend-api/ps/plugins/suggested/codex?scope=GLOBAL", server.uri() )] ); } #[tokio::test] async fn remote_installed_plugins_paginate_across_all_scopes_without_download_urls() { let server = MockServer::start().await; let installed_plugin = |scope: RemotePluginScope, id: &str, name: &str| { let mut plugin = directory_plugin(id, name); plugin.scope = scope; if scope == RemotePluginScope::Workspace { plugin.discoverability = Some(RemotePluginShareDiscoverability::Listed); } let mut plugin = serde_json::to_value(plugin).expect("serialize installed plugin"); plugin["enabled"] = serde_json::json!(true); plugin }; let global = installed_plugin(RemotePluginScope::Global, "plugin-global", "global-plugin"); let user = installed_plugin(RemotePluginScope::User, "plugin-user", "user-plugin"); let workspace = installed_plugin( RemotePluginScope::Workspace, "plugin-workspace", "workspace-plugin", ); Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param_is_missing("scope")) .and(query_param("limit", "200")) .and(query_param_is_missing("includeDownloadUrls")) .and(query_param_is_missing("pageToken")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [user.clone(), workspace.clone()], "pagination": {"next_page_token": "next page/+"}, }))) .expect(1) .mount(&server) .await; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param_is_missing("scope")) .and(query_param("limit", "200")) .and(query_param_is_missing("includeDownloadUrls")) .and(query_param("pageToken", "next page/+")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [global.clone()], "pagination": {"next_page_token": null}, }))) .expect(1) .mount(&server) .await; let (config, selected_urls) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let installed_plugins = fetch_remote_installed_plugins(&config, Some(&auth)) .await .expect("all-scopes installed plugin request should succeed"); let expected_plugins = [user, global, workspace] .into_iter() .map(|plugin| { let plugin = serde_json::from_value(plugin).expect("deserialize installed plugin"); remote_installed_plugin_to_cache_entry(&plugin).expect("valid installed plugin") }) .collect::>(); assert_eq!(installed_plugins, expected_plugins); assert_eq!( recorded_http_client_urls(&selected_urls), vec![ format!( "{}/backend-api/ps/plugins/installed?limit=200", server.uri() ), format!( "{}/backend-api/ps/plugins/installed?limit=200&pageToken=next+page%2F%2B", server.uri() ), ] ); } // Skipping display assets must survive pagination without requesting bundle downloads. #[tokio::test] async fn installed_plugins_skip_display_resolution_on_every_page() { let server = MockServer::start().await; for (cursor, next_cursor) in [(None, Some("next page/+")), (Some("next page/+"), None)] { let mock = Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param("includeDisplayAssets", "false")) .and(query_param_is_missing("includeDownloadUrls")); let mock = match cursor { Some(cursor) => mock.and(query_param("pageToken", cursor)), None => mock.and(query_param_is_missing("pageToken")), }; mock.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [], "pagination": {"next_page_token": next_cursor}, }))) .expect(1) .mount(&server) .await; } let (config, selected_urls) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let plugins = fetch_remote_installed_plugins_with_display_assets( &config, Some(&auth), InstalledPluginDisplayAssets::Skip, ) .await .expect("runtime metadata request succeeds"); assert_eq!(plugins, Vec::new()); assert_eq!( recorded_http_client_urls(&selected_urls), vec![ format!( "{}/backend-api/ps/plugins/installed?limit=200&includeDisplayAssets=false", server.uri() ), format!( "{}/backend-api/ps/plugins/installed?limit=200&includeDisplayAssets=false&pageToken=next+page%2F%2B", server.uri() ), ] ); } #[tokio::test] async fn remote_installed_plugins_reject_cyclic_cursors() { let server = MockServer::start().await; for (cursor, next_cursor) in [ (None, "cursor-a"), (Some("cursor-a"), "cursor-b"), (Some("cursor-b"), "cursor-a"), ] { let mock = Mock::given(method("GET")).and(path("/backend-api/ps/plugins/installed")); let mock = match cursor { Some(cursor) => mock.and(query_param("pageToken", cursor)), None => mock.and(query_param_is_missing("pageToken")), }; mock.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [], "pagination": {"next_page_token": next_cursor}, }))) .up_to_n_times(1) .expect(1) .mount(&server) .await; } let (config, _) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let error = fetch_remote_installed_plugins(&config, Some(&auth)) .await .expect_err("a cursor cycle must not produce an installed inventory"); assert!(matches!( error, RemotePluginCatalogError::UnexpectedResponse(_) )); } #[tokio::test] async fn remote_installed_plugins_require_a_complete_inventory_within_the_page_limit() { for complete in [false, true] { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .respond_with(move |request: &wiremock::Request| { let page = request .url .query_pairs() .find(|(name, _)| name == "pageToken") .map_or(0, |(_, value)| value.parse::().unwrap()); let next_cursor = (!complete || page + 1 < MAX_REMOTE_INSTALLED_PLUGIN_PAGES) .then(|| (page + 1).to_string()); ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [], "pagination": {"next_page_token": next_cursor}, })) }) .up_to_n_times(MAX_REMOTE_INSTALLED_PLUGIN_PAGES as u64) .expect(MAX_REMOTE_INSTALLED_PLUGIN_PAGES as u64) .mount(&server) .await; let (config, _) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let result = fetch_remote_installed_plugins(&config, Some(&auth)).await; if complete { assert_eq!( result.expect("the last permitted page can complete the inventory"), Vec::new() ); } else { assert!(matches!( result, Err(RemotePluginCatalogError::UnexpectedResponse(_)) )); } } } #[tokio::test] async fn remote_installed_plugins_timeout_cancels_a_late_page_after_successful_pages() { let server = MockServer::start().await; let request_started = Arc::new(tokio::sync::Notify::new()); let started = Arc::clone(&request_started); let (release_response, released) = std::sync::mpsc::channel(); let released = std::sync::Mutex::new(released); Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .respond_with(move |request: &wiremock::Request| { let page = request .url .query_pairs() .find(|(name, _)| name == "pageToken") .map_or(0, |(_, value)| value.parse::().unwrap()); started.notify_one(); // Wiremock serves requests on its own runtime. Dropping the sender also releases it. let _ = released.lock().unwrap().recv(); ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [], "pagination": {"next_page_token": (page + 1).to_string()}, })) }) .up_to_n_times(3) .expect(3) .mount(&server) .await; let (config, _) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let fetch = fetch_remote_installed_plugins(&config, Some(&auth)); tokio::pin!(fetch); // Each page stays below the individual request timeout, but together exceeds the fetch budget. // Keep time running during socket I/O so Tokio cannot auto-advance past a response in flight. for _ in 0..2 { tokio::select! { _ = request_started.notified() => {} result = fetch.as_mut() => panic!("inventory fetch finished before its next page: {result:?}"), } tokio::time::pause(); tokio::time::advance(Duration::from_secs(21)).await; tokio::time::resume(); release_response.send(()).unwrap(); } tokio::select! { _ = request_started.notified() => {} result = fetch.as_mut() => panic!("inventory fetch finished before its late page: {result:?}"), } tokio::time::pause(); tokio::time::advance(Duration::from_secs(21)).await; let result = fetch.await; tokio::time::resume(); drop(release_response); assert!(matches!( result, Err(RemotePluginCatalogError::InstalledPluginsFetchTimeout) )); } #[tokio::test] async fn remote_catalog_cache_modes_control_refresh_and_persist_fetched_results() { let server = MockServer::start().await; let plugin = directory_plugin("plugin-gmail", "gmail"); Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/list")) .and(query_param("scope", "GLOBAL")) .and(query_param_is_missing("collection")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "plugins": [plugin], "pagination": {"next_page_token": null}, }))) .expect(3) .mount(&server) .await; let codex_home = tempfile::tempdir().expect("create codex home"); let (config, selected_urls) = recording_remote_plugin_service_config(format!("{}/backend-api", server.uri())); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); for (mode, expire_cache, expected_refresh_needed, expected_cache_used) in [ ( RemotePluginCatalogCacheMode::PreferFreshCache, false, false, false, ), ( RemotePluginCatalogCacheMode::PreferFreshCache, false, false, true, ), (RemotePluginCatalogCacheMode::PreferCache, true, true, true), ( RemotePluginCatalogCacheMode::PreferFreshCache, false, false, false, ), ( RemotePluginCatalogCacheMode::ForceRefetch, false, false, false, ), ] { if expire_cache { let cache_path = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog")) .expect("read catalog cache directory") .next() .expect("catalog cache exists") .expect("read cache entry") .path(); let mut cached: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache_path).expect("read cache")) .expect("decode cache"); cached["fetched_at"] = serde_json::json!("2000-01-01T00:00:00Z"); std::fs::write( cache_path, serde_json::to_vec(&cached).expect("encode cache"), ) .expect("expire catalog cache"); } let outcome = fetch_directory_plugins_for_scope_with_cache( Some(codex_home.path()), &config, &auth, RemotePluginScope::Global, /*collection*/ None, mode, ) .await .expect("fetch directory plugins"); assert_eq!( ( outcome.plugins, outcome.cache_refresh_needed, outcome.catalog_cache_used, ), ( vec![plugin.clone()], expected_refresh_needed, expected_cache_used, ), ); } assert_eq!( recorded_http_client_urls(&selected_urls), vec![ format!( "{}/backend-api/ps/plugins/list?scope=GLOBAL&limit=200", server.uri() ); 3 ], ); assert!(has_fresh_cached_remote_plugin_catalog( codex_home.path(), &config, Some(&auth), RemotePluginScope::Global, )); } #[test] fn catalog_cache_invalidation_clears_global_collections_and_preserves_other_scopes() { let codex_home = tempfile::tempdir().expect("create codex home"); let config = RemotePluginServiceConfig::new( "https://chatgpt.com/backend-api".to_string(), crate::test_support::test_http_client_factory(), /*product_sku*/ None, ); let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let scopes = [RemotePluginScope::Global, RemotePluginScope::Workspace]; for scope in scopes { catalog_cache::write_cached_directory_plugins( codex_home.path(), &config, &auth, scope, /*collection*/ None, &[], ); } assert_eq!( cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth), &scopes), BTreeSet::from(scopes) ); catalog_cache::write_cached_directory_plugins( codex_home.path(), &config, &auth, RemotePluginScope::Global, Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY), &[], ); invalidate_cached_remote_plugin_catalog_scopes( codex_home.path(), &config, Some(&auth), &[RemotePluginScope::Global], ); assert_eq!( cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth), &scopes), BTreeSet::from([RemotePluginScope::Workspace]), ); assert!( catalog_cache::load_cached_directory_plugins::( codex_home.path(), &config, &auth, RemotePluginScope::Global, Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY), RemotePluginCatalogCacheMode::PreferCache, ) .is_none() ); } #[test] fn build_remote_marketplace_preserves_directory_order_and_appends_installed_only_plugins() { let directory_plugins = vec![ directory_plugin("plugin-z", "zulu"), directory_plugin("plugin-m", "mike"), ]; let installed_plugins = vec![RemotePluginInstalledItem { plugin: directory_plugin("plugin-a", "alpha"), installed_at: None, enabled: true, disabled_skill_names: Vec::new(), }]; let marketplace = build_remote_marketplace( "marketplace", "Marketplace", directory_plugins, installed_plugins, /*include_installed_only*/ true, ) .expect("marketplace should be valid") .expect("marketplace should not be empty"); assert_eq!( marketplace .plugins .into_iter() .map(|plugin| plugin.remote_plugin_id) .collect::>(), vec!["plugin-z", "plugin-m", "plugin-a"] ); } #[test] fn installation_policy_source_is_preserved_across_remote_summary_paths() { let mut directory_plugin = directory_plugin("plugin-linear", "linear"); directory_plugin.installation_policy_source = Some(RemotePluginInstallPolicySource::ImplicitCanonicalApp); let installed_plugin = RemotePluginInstalledItem { plugin: directory_plugin.clone(), installed_at: None, enabled: true, disabled_skill_names: Vec::new(), }; let marketplace = build_remote_marketplace( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, vec![directory_plugin], vec![installed_plugin.clone()], /*include_installed_only*/ false, ) .expect("marketplace should be valid") .expect("marketplace should not be empty"); assert_eq!( marketplace .plugins .into_iter() .map(|plugin| plugin.install_policy_source) .collect::>(), vec![Some(PluginInstallPolicySource::ImplicitCanonicalApp)] ); let mut installed_plugin = installed_plugin; installed_plugin.plugin.installation_policy_source = Some(RemotePluginInstallPolicySource::WorkspaceSetting); let installed_plugin = remote_installed_plugin_to_cache_entry(&installed_plugin) .expect("installed plugin should be valid"); let marketplaces = group_remote_installed_plugins_by_marketplaces( &[installed_plugin], &[REMOTE_GLOBAL_MARKETPLACE_NAME], ); assert_eq!( marketplaces .into_iter() .flat_map(|marketplace| marketplace.plugins) .map(|plugin| plugin.install_policy_source) .collect::>(), vec![Some(PluginInstallPolicySource::WorkspaceSetting)] ); } #[test] fn plan_eligibility_is_preserved_across_remote_summary_paths() { let mut directory_plugin = directory_plugin("plugin-gmail", "gmail"); directory_plugin.installation_policy = PluginInstallPolicy::NotAvailable; directory_plugin.availability = PluginAvailability::DisabledByAdmin; directory_plugin.disabled_reason = Some(PluginDisabledReason::PlanNotEligible); directory_plugin.eligible_plan_types = Some(vec![ "plus".to_string(), "pro".to_string(), "enterprise_cbp_automation".to_string(), ]); let installed_plugin = RemotePluginInstalledItem { plugin: directory_plugin.clone(), installed_at: None, enabled: false, disabled_skill_names: Vec::new(), }; let marketplace = build_remote_marketplace( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, vec![directory_plugin], Vec::new(), /*include_installed_only*/ false, ) .expect("marketplace should be valid") .expect("marketplace should not be empty"); let expected = vec![( PluginAvailability::DisabledByAdmin, Some(PluginDisabledReason::PlanNotEligible), Some(vec![ "plus".to_string(), "pro".to_string(), "enterprise_cbp_automation".to_string(), ]), )]; assert_eq!( marketplace .plugins .into_iter() .map(|plugin| ( plugin.availability, plugin.disabled_reason, plugin.eligible_plan_types, )) .collect::>(), expected ); let installed_plugin = remote_installed_plugin_to_cache_entry(&installed_plugin) .expect("installed plugin should be valid"); let marketplaces = group_remote_installed_plugins_by_marketplaces( &[installed_plugin], &[REMOTE_GLOBAL_MARKETPLACE_NAME], ); assert_eq!( marketplaces .into_iter() .flat_map(|marketplace| marketplace.plugins) .map(|plugin| ( plugin.availability, plugin.disabled_reason, plugin.eligible_plan_types, )) .collect::>(), expected ); } #[test] fn unknown_plugin_disabled_reason_preserves_remote_catalog_compatibility() { let plugin = directory_plugin("plugin-gmail", "gmail"); let mut plugin_json = serde_json::to_value(plugin).expect("plugin should serialize"); plugin_json["disabled_reason"] = serde_json::json!("future_disabled_reason"); let plugin: RemotePluginDirectoryItem = serde_json::from_value(plugin_json).expect("unknown reason should deserialize"); assert_eq!(plugin.disabled_reason, Some(PluginDisabledReason::Unknown)); } #[test] fn installation_interstitial_requirement_is_preserved_across_remote_summary_paths() { let mut directory_plugin = directory_plugin("plugin-linear", "linear"); directory_plugin.must_show_installation_interstitial = Some(true); let marketplace = build_remote_marketplace( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, vec![directory_plugin.clone()], Vec::new(), /*include_installed_only*/ false, ) .expect("marketplace should be valid") .expect("marketplace should not be empty"); assert_eq!( marketplace .plugins .into_iter() .map(|plugin| plugin.must_show_installation_interstitial) .collect::>(), vec![Some(true)] ); directory_plugin.must_show_installation_interstitial = Some(false); let installed_plugin = remote_installed_plugin_to_cache_entry(&RemotePluginInstalledItem { plugin: directory_plugin, installed_at: None, enabled: true, disabled_skill_names: Vec::new(), }) .expect("installed plugin should be valid"); let marketplaces = group_remote_installed_plugins_by_marketplaces( &[installed_plugin], &[REMOTE_GLOBAL_MARKETPLACE_NAME], ); assert_eq!( marketplaces .into_iter() .flat_map(|marketplace| marketplace.plugins) .map(|plugin| plugin.must_show_installation_interstitial) .collect::>(), vec![Some(false)] ); } #[test] fn missing_installation_interstitial_requirement_deserializes_to_none() { let plugin = directory_plugin("plugin-linear", "linear"); let mut plugin_json = serde_json::to_value(plugin).expect("plugin should serialize"); plugin_json .as_object_mut() .expect("plugin should serialize to an object") .remove("must_show_installation_interstitial"); let plugin: RemotePluginDirectoryItem = serde_json::from_value(plugin_json).expect("missing requirement should deserialize"); assert_eq!(plugin.must_show_installation_interstitial, None); } #[test] fn unknown_installation_policy_source_maps_to_none() { let plugin = directory_plugin("plugin-linear", "linear"); let mut plugin_json = serde_json::to_value(plugin).expect("plugin should serialize"); plugin_json["installation_policy_source"] = serde_json::Value::String("FUTURE_POLICY_SOURCE".to_string()); let plugin: RemotePluginDirectoryItem = serde_json::from_value(plugin_json).expect("unknown source should deserialize"); let summary = build_remote_plugin_summary(&plugin, /*installed_plugin*/ None) .expect("summary should be valid"); assert_eq!(summary.install_policy_source, None); } #[test] fn scheduled_task_metadata_distinguishes_unavailable_from_empty() { let release = serde_json::json!({ "display_name": "Example", "description": "Example plugin", "interface": {}, }); let without_metadata: RemotePluginReleaseResponse = serde_json::from_value(release.clone()).expect("release should deserialize"); assert_eq!(without_metadata.scheduled_tasks, None); let mut with_empty_metadata = release; with_empty_metadata["scheduled_tasks"] = serde_json::json!([]); let with_empty_metadata: RemotePluginReleaseResponse = serde_json::from_value(with_empty_metadata).expect("release should deserialize"); assert_eq!(with_empty_metadata.scheduled_tasks, Some(Vec::new())); } #[test] fn workspace_share_context_preserves_publish_capability() { let mut plugin = directory_plugin("plugin-workspace", "workspace plugin"); plugin.scope = RemotePluginScope::Workspace; plugin.discoverability = Some(RemotePluginShareDiscoverability::Private); plugin.can_publish_to_workspace = Some(true); let context = remote_plugin_share_context(&plugin) .expect("workspace plugin should be valid") .expect("workspace plugin should have share context"); assert_eq!(context.can_publish_to_workspace, Some(true)); } fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { RemotePluginDirectoryItem { canonical_app_id: None, id: id.to_string(), name: name.to_string(), scope: RemotePluginScope::Global, discoverability: None, creator_account_user_id: None, creator_name: None, share_url: None, share_principals: None, can_publish_to_workspace: None, installation_policy: PluginInstallPolicy::Available, installation_policy_source: None, must_show_installation_interstitial: None, authentication_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, disabled_reason: None, eligible_plan_types: None, release: RemotePluginReleaseResponse { version: None, display_name: name.to_string(), description: String::new(), bundle_download_url: None, app_ids: Vec::new(), app_manifest: None, app_templates: Vec::new(), keywords: Vec::new(), interface: RemotePluginReleaseInterfaceResponse { short_description: None, long_description: None, developer_name: None, category: None, capabilities: Vec::new(), website_url: None, privacy_policy_url: None, terms_of_service_url: None, brand_color: None, default_prompt: None, default_prompts: None, composer_icon_url: None, logo_url: None, logo_url_dark: None, screenshot_urls: Vec::new(), }, skills: Vec::new(), onboarding_skill_name: None, mcp_servers: Vec::new(), scheduled_tasks: None, }, } } #[test] fn remote_plugin_interface_maps_dark_logo_url() { let mut plugin = directory_plugin("plugin-linear", "linear"); plugin.release.interface.logo_url_dark = Some("https://example.com/linear/logo-dark.png".to_string()); assert_eq!( remote_plugin_interface_to_info(&plugin) .expect("plugin interface") .logo_url_dark, Some("https://example.com/linear/logo-dark.png".to_string()) ); } fn item(name: &str) -> RecommendedPluginItem { RecommendedPluginItem { id: format!("plugin_{name}"), name: name.to_string(), display_name: name.to_string(), } } #[test] fn recommended_plugins_enabled_flag_selects_endpoint_or_legacy_mode() { let disabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ "enabled": false, "plugins": [{"id": "plugin_github", "name": "github", "display_name": "GitHub"}] })) .expect("response should deserialize"); assert_eq!( recommended_plugins_mode(disabled), RecommendedPluginsMode::Legacy ); let enabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ "enabled": true, "plugins": [] })) .expect("response should deserialize"); assert_eq!( recommended_plugins_mode(enabled), RecommendedPluginsMode::Endpoint { plugins: Vec::new() } ); } #[test] fn recommended_plugins_require_remote_install_identity() { let response = serde_json::from_value::(serde_json::json!({ "enabled": true, "plugins": [{"name": "github", "display_name": "GitHub"}] })); assert!(response.is_err()); } #[test] fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() { let mut plugins = (0..=52) .rev() .map(|index| item(&format!("plugin-{index:02}"))) .collect::>(); plugins.push(item("plugin-00")); plugins.push(item("not/a/plugin")); let mode = recommended_plugins_mode(RecommendedPluginsResponse { enabled: true, plugins, }); let RecommendedPluginsMode::Endpoint { plugins } = mode else { panic!("expected endpoint mode"); }; assert_eq!(plugins.len(), MAX_RECOMMENDED_PLUGINS); assert_eq!( plugins.first(), Some(&RecommendedPlugin { config_id: "plugin-00@openai-curated-remote".to_string(), remote_plugin_id: "plugin_plugin-00".to_string(), display_name: "plugin-00".to_string(), }) ); assert_eq!( plugins.last(), Some(&RecommendedPlugin { config_id: "plugin-49@openai-curated-remote".to_string(), remote_plugin_id: "plugin_plugin-49".to_string(), display_name: "plugin-49".to_string(), }) ); } #[test] fn recommended_plugins_bound_model_visible_fields() { let overlong_name = "n".repeat(MAX_RECOMMENDED_PLUGIN_NAME_LEN + 1); let overlong_display_name = "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN + 1); let mode = recommended_plugins_mode(RecommendedPluginsResponse { enabled: true, plugins: vec![ item(&overlong_name), RecommendedPluginItem { id: "plugin_bounded".to_string(), name: "bounded".to_string(), display_name: overlong_display_name, }, RecommendedPluginItem { id: "plugin_fallback".to_string(), name: "fallback".to_string(), display_name: String::new(), }, ], }); assert_eq!( mode, RecommendedPluginsMode::Endpoint { plugins: vec![ RecommendedPlugin { config_id: "bounded@openai-curated-remote".to_string(), remote_plugin_id: "plugin_bounded".to_string(), display_name: "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN), }, RecommendedPlugin { config_id: "fallback@openai-curated-remote".to_string(), remote_plugin_id: "plugin_fallback".to_string(), display_name: "fallback".to_string(), }, ], } ); } #[test] fn recommended_plugins_accept_codex_suggestion_items() { let response: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({ "enabled": true, "plugins": [{"id": "plugin_box", "name": "box", "display_name": "Box"}] })) .expect("compact response should deserialize"); assert_eq!( recommended_plugins_mode(response), RecommendedPluginsMode::Endpoint { plugins: vec![RecommendedPlugin { config_id: "box@openai-curated-remote".to_string(), remote_plugin_id: "plugin_box".to_string(), display_name: "Box".to_string(), }], } ); } #[test] fn recommended_plugins_ignore_invalid_remote_plugin_ids() { let mode = recommended_plugins_mode(RecommendedPluginsResponse { enabled: true, plugins: vec![RecommendedPluginItem { id: "not/a/plugin".to_string(), name: "sample".to_string(), display_name: "Sample".to_string(), }], }); assert_eq!( mode, RecommendedPluginsMode::Endpoint { plugins: Vec::new(), } ); }