From fbd575ab4a8f786a535223856e4b8fd8beb29458 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 17:31:14 -0700 Subject: [PATCH] TUI Plugin Sharing 4 - cover remote plugin catalog flows (#26704) Remote plugin catalogs now span workspace, shared, and local sources, so their TUI behavior needs focused regression coverage across loading, navigation, actions, and refreshes. This PR: - Covers product labels and rendered loading/error states for Workspace, Shared with me, Shared with me (link), and Local tabs, including tab persistence across refresh and detail navigation. - Covers remote/local deduplication, Installed-tab remote detail routing, marketplace load-error handling, and disabled install/uninstall navigation. - Adds a full detail snapshot for local shared-plugin metadata plus focused snapshots for marketplace labels and admin-disabled status. - Verifies shared plugins remain eligible for mentions and successful uninstalls trigger a catalog refresh. --- codex-rs/tui/src/app/plugin_mentions.rs | 52 +++- codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/app/tests/plugin_catalog.rs | 49 +++ ...in_detail_popup_local_share_read_only.snap | 19 ++ ...ts__plugins_popup_curated_marketplace.snap | 4 +- ..._tests__plugins_popup_search_filtered.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 72 ++++- .../chatwidget/tests/plugin_catalog_tests.rs | 288 ++++++++++++++++++ .../chatwidget/tests/popups_and_settings.rs | 204 ++++++++----- codex-rs/tui/src/custom_terminal.rs | 2 +- codex-rs/tui/src/tui.rs | 2 + codex-rs/tui/src/tui/test_support.rs | 28 ++ 13 files changed, 643 insertions(+), 82 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/plugin_catalog.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs create mode 100644 codex-rs/tui/src/tui/test_support.rs diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index 5d94f51c0..f12dee72c 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -90,12 +90,15 @@ mod tests { use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; + use codex_app_server_protocol::PluginShareContext; + use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use pretty_assertions::assert_eq; #[test] fn plugin_mentions_use_plugin_list_summaries_and_gui_eligibility() { let active = plugin_summary("active"); + let active_shared = shared_plugin_summary("active-shared"); let mut disabled_by_admin = plugin_summary("disabled-by-admin"); disabled_by_admin.availability = PluginAvailability::DisabledByAdmin; let mut disabled = plugin_summary("disabled"); @@ -108,7 +111,13 @@ mod tests { name: "server-marketplace".to_string(), path: None, interface: None, - plugins: vec![active, disabled_by_admin, disabled, uninstalled], + plugins: vec![ + active, + active_shared, + disabled_by_admin, + disabled, + uninstalled, + ], }], marketplace_load_errors: Vec::new(), featured_plugin_ids: Vec::new(), @@ -116,17 +125,42 @@ mod tests { assert_eq!( plugin_mentions_from_list_response(response), - vec![PluginCapabilitySummary { - config_name: "active@server-marketplace".to_string(), - display_name: "active".to_string(), - description: Some("server-marketplace".to_string()), - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), - }] + vec![ + PluginCapabilitySummary { + config_name: "active@server-marketplace".to_string(), + display_name: "active".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + PluginCapabilitySummary { + config_name: "active-shared@server-marketplace".to_string(), + display_name: "active-shared".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + ] ); } + fn shared_plugin_summary(name: &str) -> PluginSummary { + PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: format!("plugins~{name}"), + remote_version: Some("7".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some(format!("https://chatgpt.com/codex/plugins/share/{name}")), + creator_account_user_id: None, + creator_name: Some("Test User".to_string()), + share_principals: None, + }), + ..plugin_summary(name) + } + } + fn plugin_summary(name: &str) -> PluginSummary { PluginSummary { id: format!("{name}@server-marketplace"), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0fc561cb2..6c55accab 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1,6 +1,7 @@ //! App-level orchestration tests for the TUI. mod model_catalog; +mod plugin_catalog; mod session_summary; mod startup; diff --git a/codex-rs/tui/src/app/tests/plugin_catalog.rs b/codex-rs/tui/src/app/tests/plugin_catalog.rs new file mode 100644 index 000000000..251b8817b --- /dev/null +++ b/codex-rs/tui/src/app/tests/plugin_catalog.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use codex_app_server_protocol::PluginUninstallResponse; + +use super::*; + +#[tokio::test] +async fn successful_plugin_uninstall_dispatches_plugin_list_refresh() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + while app_event_rx.try_recv().is_ok() {} + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::PluginUninstallLoaded { + cwd: cwd.clone(), + plugin_id: "plugin-docs".to_string(), + plugin_display_name: "Docs".to_string(), + result: Ok(PluginUninstallResponse {}), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let refresh_result = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match app_event_rx.recv().await { + Some(AppEvent::PluginsLoaded { + cwd: event_cwd, + result, + }) if event_cwd == cwd => break result, + Some(_) => {} + None => panic!("app event channel closed before plugin refresh completed"), + } + } + }) + .await + .expect("dispatcher should initiate a plugin list refresh"); + refresh_result.expect("plugin list refresh should succeed"); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap new file mode 100644 index 000000000..31ff7ba46 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) +--- + Plugins + Docs · Can be installed · Local + Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. + Workspace docs. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install + Version remote 7 + Sharing Private · creator Test User · https://chatgpt.com/codex/plugins/share/docs + Skills No plugin skills. + Hooks No plugin hooks. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 9f828ab3b..2ec91937f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -6,12 +6,12 @@ expression: popup Browse plugins from available marketplaces. Installed 1 of 4 available plugins. - [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace + [All Plugins] Installed (1) OpenAI Curated Workspace Shared with me Repo Marketplace Add Marketplace Type to search plugins › [ ] Alpha Sync Disabled Space to enable; Enter view details. [-] Bravo Search Available · OpenAI Curated · Search docs and tickets. - [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugi… + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. [-] Starter Available by default · OpenAI Curated · Included by default. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index 849b90b2c..bfb0affb2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -7,7 +7,7 @@ expression: popup Browse plugins from available marketplaces. Installed 0 of 3 available plugins. - [All Plugins] Installed (0) OpenAI Curated Add Marketplace + [All Plugins] Installed (0) OpenAI Curated Workspace Shared with me Add Marketplace sla › [-] Slack Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e4196224d..1af814eb7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -244,6 +244,8 @@ mod history_replay; mod mcp_startup; mod permissions; mod plan_mode; +#[path = "tests/plugin_catalog_tests.rs"] +mod plugin_catalog; mod popups_and_settings; mod review_mode; mod side; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index abedbe567..06c440bfb 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1284,6 +1284,13 @@ pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { .abs() } +pub(super) fn plugins_test_personal_marketplace_path() -> AbsolutePathBuf { + dirs::home_dir() + .expect("home directory should be available") + .join(".agents/plugins/marketplace.json") + .abs() +} + pub(super) fn plugins_test_interface( display_name: Option<&str>, short_description: Option<&str>, @@ -1372,6 +1379,21 @@ pub(super) fn plugins_test_remote_summary( } } +pub(super) fn plugins_test_remote_marketplace( + name: &str, + display_name: &str, + plugins: Vec, +) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(display_name.to_string()), + }), + plugins, + } +} + pub(super) fn plugins_test_curated_marketplace( plugins: Vec, ) -> PluginMarketplaceEntry { @@ -1408,11 +1430,23 @@ pub(super) fn plugins_test_response( pub(super) fn render_loaded_plugins_popup( chat: &mut ChatWidget, - response: PluginListResponse, + mut response: PluginListResponse, ) -> String { let cwd = chat.config.cwd.clone(); + let remote_marketplaces = response + .marketplaces + .iter() + .filter(|marketplace| marketplace.path.is_none()) + .cloned() + .collect(); + response + .marketplaces + .retain(|marketplace| marketplace.path.is_some()); + let response_for_refresh = response.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response_for_refresh)); + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), remote_marketplaces, Vec::new()); render_bottom_popup(chat, /*width*/ 100) } @@ -1470,12 +1504,48 @@ pub(super) fn plugins_test_detail( } } +pub(super) fn plugins_test_remote_detail( + marketplace_name: &str, + summary: PluginSummary, + description: Option<&str>, +) -> PluginDetail { + PluginDetail { + marketplace_name: marketplace_name.to_string(), + marketplace_path: None, + summary, + share_url: None, + description: description.map(str::to_string), + skills: Vec::new(), + hooks: Vec::new(), + apps: Vec::new(), + app_templates: Vec::new(), + mcp_servers: Vec::new(), + } +} + pub(super) fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { popup .find(needle) .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) } +pub(super) fn select_plugins_tab_containing( + chat: &mut ChatWidget, + width: u16, + visible_text: &str, +) -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, width); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, width); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); +} + pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { for ch in query.chars() { chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); diff --git a/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs b/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs new file mode 100644 index 000000000..b133c7ed0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn plugins_popup_uses_product_labels_for_remote_and_personal_tabs() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-directory", + "Raw Workspace Directory", + vec![plugins_test_remote_summary( + "plugins~Plugin_buildkite", + "buildkite", + Some("Buildkite"), + Some("Workspace CI."), + /*installed*/ false, + )], + ), + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Raw Shared Private", + vec![plugins_test_remote_summary( + "plugins~Plugin_docs", + "docs", + Some("Docs"), + Some("Shared docs."), + /*installed*/ false, + )], + ), + plugins_test_remote_marketplace( + "workspace-shared-with-me-unlisted", + "Raw Shared Link", + vec![plugins_test_remote_summary( + "plugins~Plugin_link", + "link", + Some("Link Share"), + Some("Shared by link."), + /*installed*/ false, + )], + ), + PluginMarketplaceEntry { + name: "codex-curated".to_string(), + path: Some(plugins_test_personal_marketplace_path()), + interface: Some(MarketplaceInterface { + display_name: Some("Personal".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-local-docs", + "local-docs", + Some("Local Docs"), + Some("Local editable docs."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ]), + ); + + let rows = [ + ( + "[Workspace]", + "Workspace.", + "Buildkite", + "Raw Workspace Directory.", + ), + ( + "[Shared with me]", + "Shared with me.", + "Docs", + "Raw Shared Private.", + ), + ( + "[Shared with me (link)]", + "Shared with me (link).", + "Link Share", + "Raw Shared Link.", + ), + ("[Local]", "Local.", "Local Docs", "Personal."), + ] + .into_iter() + .map(|(selected_tab, product_label, plugin_name, raw_label)| { + let popup = select_plugins_tab_containing(&mut chat, /*width*/ 120, selected_tab); + assert!( + popup.contains(product_label) + && popup.contains(plugin_name) + && !popup.contains(raw_label), + "expected {selected_tab} to use its product label, got:\n{popup}" + ); + popup + .lines() + .find(|line| line.contains(plugin_name)) + .expect("expected plugin row") + .split_whitespace() + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n"); + + insta::assert_snapshot!( + rows, + @r###" + › [-] Buildkite Available Press Enter to install or view plugin details. + › [-] Docs Available Press Enter to install or view plugin details. + › [-] Link Share Available Press Enter to install or view plugin details. + › [-] Local Docs Available Press Enter to install or view plugin details. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_preserves_workspace_tab_across_load_and_detail_navigation() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let workspace_marketplace = plugins_test_remote_marketplace( + "workspace-directory", + "Raw Workspace Directory", + vec![plugins_test_remote_summary( + "plugins~Plugin_buildkite", + "buildkite", + Some("Buildkite"), + Some("Buildkite pipelines."), + /*installed*/ false, + )], + ); + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let loading_popup = + select_plugins_tab_containing(&mut chat, /*width*/ 100, "Loading Workspace plugins."); + assert!( + loading_popup.contains("Loading Workspace plugins."), + "expected Workspace loading tab before remote sections resolve, got:\n{loading_popup}" + ); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + vec![workspace_marketplace.clone()], + Vec::new(), + ); + let workspace_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + workspace_popup.contains("Workspace.") + && workspace_popup.contains("Buildkite") + && !workspace_popup.contains("Loading Workspace plugins."), + "expected remote section refresh to keep the Workspace tab active, got:\n{workspace_popup}" + ); + + chat.open_plugin_detail_loading_popup("Buildkite"); + chat.open_plugins_list( + cwd.to_path_buf(), + plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + workspace_marketplace, + ]), + ); + let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + reopened_popup.contains("Workspace.") && reopened_popup.contains("Buildkite"), + "expected Back to plugins to preserve the Workspace tab, got:\n{reopened_popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_local_dedupe_prefers_installed_remote_after_mapped_shares() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let local_summary = PluginSummary { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local curated docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let cwd = chat.config.cwd.clone(); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![local_summary])]), + ); + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + vec![plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Remote installed docs plugin."), + /*installed*/ true, + )], + )], + Vec::new(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + let PluginsCacheState::Ready(response) = &chat.plugins_cache else { + panic!("expected cached plugins after remote section refresh"); + }; + assert_eq!( + response + .marketplaces + .iter() + .map(|marketplace| marketplace.name.as_str()) + .collect::>(), + vec![ + OPENAI_CURATED_MARKETPLACE_NAME, + "workspace-shared-with-me-private" + ] + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins."), + "expected header count to reflect deduped plugin rows, got:\n{popup}" + ); + assert!( + all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Local curated docs plugin."), + "expected installed remote duplicate to win when local row is not a mapped share, got:\n{all_plugins_row}" + ); +} + +#[tokio::test] +async fn plugin_detail_not_installable_plugin_disables_install_action() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = plugins_test_summary( + "plugin-internal", + "internal", + Some("Internal"), + Some("Internal only."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::NotAvailable, + ); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![summary.clone()]), + ])), + ); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: plugins_test_detail(summary, Some("Internal only."), &[], &[], &[], &[]), + }), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let install_row = popup + .lines() + .find(|line| line.contains("Install plugin")) + .expect("expected install row"); + assert!( + install_row.contains("This plugin is not installable from this marketplace."), + "expected disabled not-installable row, got:\n{install_row}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 100), + popup, + "expected navigation to skip the disabled install row" + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 281d97305..f97d93a0c 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -5,9 +5,12 @@ use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::MarketplaceLoadErrorInfo; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSource; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -197,7 +200,8 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ PluginInstallPolicy::Available, )]), ]); - let popup = render_loaded_plugins_popup(&mut chat, response); + render_loaded_plugins_popup(&mut chat, response); + let popup = render_bottom_popup(&chat, /*width*/ 320); assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup); assert!( popup.contains("Hidden Repo Plugin"), @@ -214,6 +218,27 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[tokio::test] +async fn plugins_popup_keeps_loaded_marketplace_state_with_load_errors() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let mut response = plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]); + response + .marketplace_load_errors + .push(MarketplaceLoadErrorInfo { + marketplace_path: plugins_test_absolute_path("marketplaces/broken"), + message: "failed to load marketplace manifest".to_string(), + }); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert!( + popup.contains("No marketplace plugins available") + && !popup.contains("Marketplace unavailable"), + "expected /plugins to keep the loaded marketplace state, got:\n{popup}" + ); +} + #[tokio::test] async fn plugins_popup_truncates_long_descriptions_in_list_rows() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -272,14 +297,14 @@ async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = select_plugins_tab_containing( + &mut chat, + /*width*/ 100, + "Add a marketplace from a Git repo or local root.", + ); assert!( popup.contains("Add a marketplace from a Git repo or local root."), - "expected Add Marketplace tab, got:\n{popup}" + "expected Add marketplace tab, got:\n{popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -349,11 +374,7 @@ async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace."); assert!( popup.contains("Repo Marketplace.") && popup.contains("ctrl + u upgrade") @@ -513,10 +534,8 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - let repo_tab = render_bottom_popup(&chat, /*width*/ 100); + let repo_tab = + select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace."); assert!( repo_tab.contains("Repo Marketplace.") && repo_tab.contains("ctrl + u upgrade") @@ -839,31 +858,23 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded( cwd.to_path_buf(), - Ok(plugins_test_response(vec![PluginMarketplaceEntry { - name: "workspace-shared-with-me-private".to_string(), - path: None, - interface: Some(MarketplaceInterface { - display_name: Some("Shared with me".to_string()), - }), - plugins: vec![summary.clone()], - }])), + Ok(plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![summary.clone()], + ), + ])), ); chat.add_plugins_output(); chat.on_plugin_detail_loaded( cwd.to_path_buf(), Ok(PluginReadResponse { - plugin: PluginDetail { - marketplace_name: "workspace-shared-with-me-private".to_string(), - marketplace_path: None, + plugin: plugins_test_remote_detail( + "workspace-shared-with-me-private", summary, - share_url: None, - description: Some("Installed shared Linear plugin.".to_string()), - skills: Vec::new(), - hooks: Vec::new(), - apps: Vec::new(), - app_templates: Vec::new(), - mcp_servers: Vec::new(), - }, + Some("Installed shared Linear plugin."), + ), }), ); @@ -894,7 +905,7 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { #[tokio::test] async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let summary = PluginSummary { @@ -912,31 +923,23 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded( cwd.to_path_buf(), - Ok(plugins_test_response(vec![PluginMarketplaceEntry { - name: "workspace-shared-with-me-private".to_string(), - path: None, - interface: Some(MarketplaceInterface { - display_name: Some("Shared with me".to_string()), - }), - plugins: vec![summary.clone()], - }])), + Ok(plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![summary.clone()], + ), + ])), ); chat.add_plugins_output(); chat.on_plugin_detail_loaded( cwd.to_path_buf(), Ok(PluginReadResponse { - plugin: PluginDetail { - marketplace_name: "workspace-shared-with-me-private".to_string(), - marketplace_path: None, + plugin: plugins_test_remote_detail( + "workspace-shared-with-me-private", summary, - share_url: None, - description: Some("Installed shared Linear plugin.".to_string()), - skills: Vec::new(), - hooks: Vec::new(), - apps: Vec::new(), - app_templates: Vec::new(), - mcp_servers: Vec::new(), - }, + Some("Installed shared Linear plugin."), + ), }), ); @@ -947,15 +950,58 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { "expected missing remote ID to disable uninstall, got:\n{popup}" ); - while rx.try_recv().is_ok() {} - assert!( - rx.try_recv().is_err(), - "expected no action after rendering disabled uninstall state" + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 120), + popup, + "expected navigation to skip the disabled uninstall row" ); } #[tokio::test] -async fn plugin_detail_admin_disabled_plugin_blocks_install() { +async fn plugin_detail_popup_shows_local_share_context_as_read_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: "plugins~Plugin_docs".to_string(), + remote_version: Some("7".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some("https://chatgpt.com/codex/plugins/share/docs".to_string()), + creator_account_user_id: None, + creator_name: Some("Test User".to_string()), + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Workspace docs."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); + chat.add_plugins_output(); + let mut detail = plugins_test_detail(summary, Some("Workspace docs."), &[], &[], &[], &[]); + detail.marketplace_path = Some(plugins_test_personal_marketplace_path()); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin: detail })); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert_chatwidget_snapshot!( + "plugin_detail_popup_local_share_read_only", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_shows_admin_disabled_status_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); @@ -984,10 +1030,17 @@ async fn plugin_detail_admin_disabled_plugin_blocks_install() { }), ); - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = render_bottom_popup(&chat, /*width*/ 120); + let status_row = popup + .lines() + .find(|line| line.contains("Disabled by admin")) + .expect("expected admin-disabled status row"); + insta::assert_snapshot!( + status_row, + @" Admin Blocked · Disabled by admin · ChatGPT Marketplace" + ); assert!( - popup.contains("Admin Blocked · Disabled by admin") - && popup.contains("This plugin is disabled by your workspace admin.") + popup.contains("This plugin is disabled by your workspace admin.") && !popup.contains("Install this plugin now."), "expected admin-disabled detail to block install, got:\n{popup}" ); @@ -1021,18 +1074,18 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), ); - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( popup.contains("Disabled by admin") && popup.contains("Press Enter to view plugin details.") && !popup.contains("Space to disable"), - "expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}" + "expected admin-disabled installed row to omit toggle hint, got:\n{popup}" ); while rx.try_recv().is_ok() {} - let before = render_bottom_popup(&chat, /*width*/ 100); + let before = render_bottom_popup(&chat, /*width*/ 120); chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - let after = render_bottom_popup(&chat, /*width*/ 100); + let after = render_bottom_popup(&chat, /*width*/ 120); assert!( rx.try_recv().is_err(), "space should not toggle admin-disabled installed plugins" @@ -1232,6 +1285,14 @@ async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share "expected installed remote duplicate to win over local mapped share, got:\n{popup}" ); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + let installed_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + installed_popup.contains("Showing 1 installed plugins.") + && installed_popup.contains("Docs"), + "expected installed remote duplicate in the Installed tab, got:\n{installed_popup}" + ); + while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -1765,7 +1826,14 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { chat.on_plugins_loaded(cwd.clone(), Ok(response.clone())); chat.add_plugins_output(); - for _ in 0..4 { + for _ in 0..10 { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("Duplicate Marketplace (2/2).") + && popup.contains("Repo Plugin") + && !popup.contains("Home Plugin") + { + break; + } chat.handle_key_event(KeyEvent::from(KeyCode::Right)); } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 995a0358f..480010391 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -223,7 +223,7 @@ where )) } - fn with_screen_size_and_cursor_position( + pub(crate) fn with_screen_size_and_cursor_position( backend: B, screen_size: Size, cursor_pos: Position, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index a0f7ade04..7bf72f7cd 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -60,6 +60,8 @@ mod frame_requester; mod job_control; mod keyboard_modes; mod terminal_stderr; +#[cfg(test)] +pub(crate) mod test_support; /// Target frame interval for UI redraw scheduling. pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL; diff --git a/codex-rs/tui/src/tui/test_support.rs b/codex-rs/tui/src/tui/test_support.rs new file mode 100644 index 000000000..5c1f30eec --- /dev/null +++ b/codex-rs/tui/src/tui/test_support.rs @@ -0,0 +1,28 @@ +use std::io; +use std::io::stdout; + +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; +use ratatui::layout::Size; + +use super::Tui; +use super::terminal_stderr::TerminalStderrGuard; +use crate::custom_terminal::Terminal; + +pub(crate) fn make_test_tui() -> io::Result { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::with_screen_size_and_cursor_position( + backend, + Size { + width: 80, + height: 24, + }, + Position { x: 0, y: 0 }, + ); + let stderr_guard = TerminalStderrGuard::install()?; + Ok(Tui::new( + terminal, + /*enhanced_keys_supported*/ false, + stderr_guard, + )) +}