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.
This commit is contained in:
canvrno-oai
2026-06-22 17:31:14 -07:00
committed by GitHub
parent 97dc6abb11
commit fbd575ab4a
13 changed files with 643 additions and 82 deletions
+43 -9
View File
@@ -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"),
+1
View File
@@ -1,6 +1,7 @@
//! App-level orchestration tests for the TUI.
mod model_catalog;
mod plugin_catalog;
mod session_summary;
mod startup;
@@ -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(())
}
@@ -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.
@@ -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
@@ -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.
+2
View File
@@ -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;
+71 -1
View File
@@ -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<PluginSummary>,
) -> 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<PluginSummary>,
) -> 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)));
@@ -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::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.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<_>>(),
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"
);
}
@@ -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));
}
+1 -1
View File
@@ -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,
+2
View File
@@ -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;
+28
View File
@@ -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<Tui> {
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,
))
}