mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
TUI Plugin Sharing 1 - add remote plugin identity (#26701)
This commit is contained in:
1 parent
51b3cd51f6
commit
fb8f1ea0d5
7 files changed
+573
-74
No files matched your search
@@ -1337,6 +1337,34 @@ pub(super) fn plugins_test_summary(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_remote_summary(
|
||||
remote_plugin_id: &str,
|
||||
name: &str,
|
||||
display_name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
installed: bool,
|
||||
) -> PluginSummary {
|
||||
PluginSummary {
|
||||
id: remote_plugin_id.to_string(),
|
||||
remote_plugin_id: Some(remote_plugin_id.to_string()),
|
||||
local_version: None,
|
||||
name: name.to_string(),
|
||||
share_context: None,
|
||||
source: PluginSource::Remote,
|
||||
installed,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(plugins_test_interface(
|
||||
display_name,
|
||||
description,
|
||||
/*long_description*/ None,
|
||||
)),
|
||||
keywords: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_curated_marketplace(
|
||||
plugins: Vec<PluginSummary>,
|
||||
) -> PluginMarketplaceEntry {
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_app_server_protocol::HookErrorInfo;
|
||||
use codex_app_server_protocol::HooksListEntry;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -714,6 +715,350 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_remote_row_opens_remote_detail() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let popup = render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-directory".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Workspace".to_string()),
|
||||
}),
|
||||
plugins: vec![plugins_test_remote_summary(
|
||||
"plugins~Plugin_calendar",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
Some("Workspace schedules."),
|
||||
/*installed*/ false,
|
||||
)],
|
||||
}]),
|
||||
);
|
||||
let remote_row = popup
|
||||
.lines()
|
||||
.find(|line| line.contains("Calendar"))
|
||||
.expect("expected remote plugin row");
|
||||
assert!(
|
||||
remote_row.contains("Available")
|
||||
&& remote_row.contains("Press Enter to install or view plugin details."),
|
||||
"expected remote plugin row to be viewable, got:\n{remote_row}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenPluginDetailLoading {
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(plugin_display_name, "Calendar");
|
||||
}
|
||||
other => panic!("expected OpenPluginDetailLoading event, got {other:?}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchPluginDetail { cwd: _, params }) => {
|
||||
assert_eq!(params.marketplace_path, None);
|
||||
assert_eq!(
|
||||
params.remote_marketplace_name,
|
||||
Some("workspace-directory".to_string())
|
||||
);
|
||||
assert_eq!(params.plugin_name, "plugins~Plugin_calendar");
|
||||
}
|
||||
other => panic!("expected FetchPluginDetail event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_remote_install_uses_remote_location() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = plugins_test_remote_summary(
|
||||
"plugins~Plugin_linear",
|
||||
"linear",
|
||||
Some("Linear"),
|
||||
Some("Issue tracking."),
|
||||
/*installed*/ false,
|
||||
);
|
||||
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()],
|
||||
}])),
|
||||
);
|
||||
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,
|
||||
summary,
|
||||
description: Some("Install shared Linear plugin.".to_string()),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Install plugin") && popup.contains("Install this plugin now."),
|
||||
"expected remote detail to offer install, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenPluginInstallLoading {
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected OpenPluginInstallLoading event, got {other:?}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchPluginInstall {
|
||||
cwd: _,
|
||||
location: crate::app_event::PluginLocation::Remote { marketplace_name },
|
||||
plugin_name,
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(marketplace_name, "workspace-shared-with-me-private");
|
||||
assert_eq!(plugin_name, "plugins~Plugin_linear");
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected remote FetchPluginInstall event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = plugins_test_remote_summary(
|
||||
"plugins~Plugin_linear",
|
||||
"linear",
|
||||
Some("Linear"),
|
||||
Some("Issue tracking."),
|
||||
/*installed*/ true,
|
||||
);
|
||||
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()],
|
||||
}])),
|
||||
);
|
||||
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,
|
||||
summary,
|
||||
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(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenPluginUninstallLoading {
|
||||
plugin_display_name,
|
||||
}) => {
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected OpenPluginUninstallLoading event, got {other:?}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::FetchPluginUninstall {
|
||||
plugin_id,
|
||||
plugin_display_name,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(plugin_id, "plugins~Plugin_linear");
|
||||
assert_eq!(plugin_display_name, "Linear");
|
||||
}
|
||||
other => panic!("expected remote FetchPluginUninstall event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
source: PluginSource::Remote,
|
||||
..plugins_test_summary(
|
||||
"linear@workspace-shared-with-me-private",
|
||||
"linear",
|
||||
Some("Linear"),
|
||||
Some("Issue tracking."),
|
||||
/*installed*/ true,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
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()],
|
||||
}])),
|
||||
);
|
||||
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,
|
||||
summary,
|
||||
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(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert!(
|
||||
popup.contains("This remote plugin did not provide an uninstall identity.")
|
||||
&& !popup.contains("Remove this plugin now."),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_admin_disabled_plugin_blocks_install() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
availability: PluginAvailability::DisabledByAdmin,
|
||||
..plugins_test_summary(
|
||||
"plugin-admin-blocked",
|
||||
"admin-blocked",
|
||||
Some("Admin Blocked"),
|
||||
Some("Blocked by policy."),
|
||||
/*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();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: plugins_test_detail(summary, Some("Blocked by policy."), &[], &[], &[], &[]),
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Admin Blocked · Disabled by 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}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected no action after rendering disabled install state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
availability: PluginAvailability::DisabledByAdmin,
|
||||
..plugins_test_summary(
|
||||
"plugin-admin-blocked",
|
||||
"admin-blocked",
|
||||
Some("Admin Blocked"),
|
||||
Some("Blocked by policy."),
|
||||
/*installed*/ true,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
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}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 100);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let after = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"space should not toggle admin-disabled installed plugins"
|
||||
);
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_error_popup_skips_disabled_row_numbering() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in new issue
Block a user