mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
97dc6abb11
commit
fbd575ab4a
@@ -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,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(())
|
||||
}
|
||||
+19
@@ -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.
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user