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
Unverified
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,
))
}