use super::*;use crate::app_event::ConnectorsSnapshot;use crate::chatwidget::connectors::ConnectorsCacheState;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::MarketplaceRemoveResponse;use codex_app_server_protocol::PluginAvailability;use codex_features::Stage;use pretty_assertions::assert_eq;#[tokio::test]async fn experimental_mode_plan_is_ignored_on_startup() { let codex_home = tempdir().expect("tempdir"); let cfg = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .cli_overrides(vec![ ( "features.collaboration_modes".to_string(), TomlValue::Boolean(true), ), ( "tui.experimental_mode".to_string(), TomlValue::String("plan".to_string()), ), ]) .build() .await .expect("config"); let resolved_model = get_model_offline_for_tests(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0), workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account: false, has_codex_backend_auth: false, model_catalog: test_model_catalog(&cfg), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, runtime_model_provider_base_url: None, initial_plan_type: None, model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; let chat = ChatWidget::new_with_app_event(init); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); assert_eq!(chat.current_model(), resolved_model);}#[tokio::test]async fn plugins_popup_loading_state_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); chat.add_plugins_output(); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( popup.contains("Loading available plugins..."), "expected /plugins to open in a loading state before the marketplace arrives, got:\n{popup}" ); assert_chatwidget_snapshot!("plugins_popup_loading_state", popup);}#[tokio::test]async fn marketplace_upgrade_loading_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); chat.open_marketplace_upgrade_loading_popup(Some("debug")); let popup = render_bottom_popup(&chat, /*width*/ 100); let upgrade_lines = popup .lines() .map(str::trim) .filter(|line| line.contains("Upgrading")) .collect::<Vec<_>>() .join(" | "); insta::assert_snapshot!( upgrade_lines, @"Upgrading debug marketplace... | › Upgrading debug marketplace... This updates when marketplace upgrade completes." );}#[tokio::test]async fn marketplace_upgrade_failure_includes_backend_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.clone(); chat.on_marketplace_upgrade_loaded( cwd.to_path_buf(), Ok(MarketplaceUpgradeResponse { selected_marketplaces: vec!["debug".to_string(), "tools".to_string()], upgraded_roots: Vec::new(), errors: vec![ MarketplaceUpgradeErrorInfo { marketplace_name: "debug".to_string(), message: "git ls-remote marketplace source failed with status 128: authentication failed".to_string(), }, MarketplaceUpgradeErrorInfo { marketplace_name: "tools".to_string(), message: "failed to validate upgraded marketplace root: marketplace root does not contain a supported manifest".to_string(), }, ], }), ); let rendered = drain_insert_history(&mut rx) .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); insta::assert_snapshot!( rendered.trim(), @"■ Failed to upgrade 2 marketplaces: debug: git ls-remote marketplace source failed with status 128: authentication failed; tools: failed to validate upgraded marketplace root: marketplace root does not contain a supported manifest" );}#[tokio::test]async fn hooks_popup_shows_list_diagnostics() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let cwd = chat.config.cwd.clone(); chat.on_hooks_loaded( cwd.to_path_buf(), Ok(HooksListResponse { data: vec![HooksListEntry { cwd: cwd.to_path_buf(), hooks: Vec::new(), warnings: vec!["skipped invalid matcher for PreToolUse".to_string()], errors: vec![HookErrorInfo { path: test_path_buf("/tmp/hooks.json"), message: "failed to parse hooks config".to_string(), }], }], }), ); let popup = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 112)); assert_chatwidget_snapshot!("hooks_popup_shows_list_diagnostics", popup);}#[tokio::test]async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let response = plugins_test_response(vec![ plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-bravo", "bravo", Some("Bravo Search"), Some("Search docs and tickets."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-alpha", "alpha", Some("Alpha Sync"), Some("Already installed but disabled."), /*installed*/ true, /*enabled*/ false, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-starter", "starter", Some("Starter"), Some("Included by default."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::InstalledByDefault, ), ]), plugins_test_repo_marketplace(vec![plugins_test_summary( "plugin-hidden", "hidden", Some("Hidden Repo Plugin"), Some("Should not be shown in /plugins."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )]), ]); let popup = render_loaded_plugins_popup(&mut chat, response); assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup); assert!( popup.contains("Hidden Repo Plugin"), "expected /plugins to include non-curated marketplaces, got:\n{popup}" ); assert!( plugins_test_popup_row_position(&popup, "Alpha Sync") < plugins_test_popup_row_position(&popup, "Bravo Search") && plugins_test_popup_row_position(&popup, "Bravo Search") < plugins_test_popup_row_position(&popup, "Hidden Repo Plugin") && plugins_test_popup_row_position(&popup, "Hidden Repo Plugin") < plugins_test_popup_row_position(&popup, "Starter"), "expected /plugins rows to sort installed plugins first, then alphabetically, 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; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-alpha", "alpha", Some("Alpha"), Some("Short description."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-verbose", "verbose", Some("Verbose Plugin"), Some("This description keeps going and going until the row would normally wrap."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]); let cwd = chat.config.cwd.to_path_buf(); chat.on_plugins_loaded(cwd, Ok(response)); chat.add_plugins_output(); let popup = render_bottom_popup(&chat, /*width*/ 70); let verbose_row = popup .lines() .find(|line| line.contains("Verbose Plugin")) .expect("expected verbose plugin row in popup"); insta::assert_snapshot!( verbose_row, @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" ); assert!( !popup .contains("This description keeps going and going until the row would normally wrap."), "expected the long plugin description to truncate instead of wrapping, got:\n{popup}" );}#[tokio::test]async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.to_path_buf(); render_loaded_plugins_popup( &mut chat, plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), ); 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); assert!( popup.contains("Add a marketplace from a Git repo or local root."), "expected Add Marketplace tab, got:\n{popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match rx.try_recv() { Ok(AppEvent::OpenMarketplaceAddPrompt) => {} other => panic!("expected OpenMarketplaceAddPrompt event, got {other:?}"), } chat.open_marketplace_add_prompt(); let prompt = render_bottom_popup(&chat, /*width*/ 100); assert!( prompt.contains("owner/repo, git URL, or local marketplace path"), "expected marketplace source prompt, got:\n{prompt}" ); chat.handle_paste("owner/repo".to_string()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match rx.try_recv() { Ok(AppEvent::OpenMarketplaceAddLoading { source }) => { assert_eq!(source, "owner/repo"); } other => panic!("expected OpenMarketplaceAddLoading event, got {other:?}"), } match rx.try_recv() { Ok(AppEvent::FetchMarketplaceAdd { cwd: event_cwd, source, }) => { assert_eq!(event_cwd, cwd); assert_eq!(source, "owner/repo"); } other => panic!("expected FetchMarketplaceAdd event, got {other:?}"), }}#[tokio::test]async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace_tab() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.to_path_buf(); let temp = tempdir().expect("tempdir"); let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config( &config_toml_path, toml::from_str::<TomlValue>( "[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n", ) .expect("marketplace config"), ); render_loaded_plugins_popup( &mut chat, plugins_test_response(vec![ plugins_test_curated_marketplace(Vec::new()), plugins_test_repo_marketplace(vec![plugins_test_summary( "plugin-debug", "debug", Some("Debug Plugin"), Some("Debug marketplace plugin."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )]), ]), ); 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); assert!( popup.contains("Repo Marketplace.") && popup.contains("ctrl + u upgrade") && popup.contains("ctrl + r remove") && popup.contains("Debug Plugin"), "expected upgradeable user-configured marketplace tab, got:\n{popup}" ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); chat.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); match rx.try_recv() { Ok(AppEvent::OpenMarketplaceUpgradeLoading { marketplace_name }) => { assert_eq!(marketplace_name, Some("repo".to_string())); } other => panic!("expected OpenMarketplaceUpgradeLoading event, got {other:?}"), } match rx.try_recv() { Ok(AppEvent::FetchMarketplaceUpgrade { cwd: event_cwd, marketplace_name, }) => { assert_eq!(event_cwd, cwd); assert_eq!(marketplace_name, Some("repo".to_string())); } other => panic!("expected FetchMarketplaceUpgrade event, got {other:?}"), } let no_more_events = rx.try_recv(); assert!( no_more_events.is_err(), "expected no duplicate marketplace upgrade events, got {no_more_events:?}" );}#[tokio::test]async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.to_path_buf(); let marketplace_root = plugins_test_absolute_path("marketplaces/debug"); let marketplace_path = plugins_test_absolute_path("marketplaces/debug/.agents/plugins/marketplace.json"); let temp = tempdir().expect("tempdir"); let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config( &config_toml_path, toml::from_str::<TomlValue>( "[marketplaces.debug]\nsource_type = \"git\"\nsource = \"https://github.com/owner/debug.git\"\n", ) .expect("marketplace config"), ); render_loaded_plugins_popup( &mut chat, plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]), ); chat.open_marketplace_add_loading_popup("owner/repo"); let loading_popup = render_bottom_popup(&chat, /*width*/ 100); assert!( !loading_popup.contains("owner/repo"), "expected marketplace loading popup to avoid echoing the source, got:\n{loading_popup}" ); chat.on_marketplace_add_loaded( cwd.clone(), "owner/repo".to_string(), Ok(MarketplaceAddResponse { marketplace_name: "debug".to_string(), installed_root: marketplace_root, already_added: false, }), ); chat.on_plugins_loaded( cwd, Ok(plugins_test_response(vec![ plugins_test_curated_marketplace(Vec::new()), PluginMarketplaceEntry { name: "debug".to_string(), path: Some(marketplace_path), interface: Some(MarketplaceInterface { display_name: Some("Debug Marketplace".to_string()), }), plugins: vec![plugins_test_summary( "plugin-debug", "debug", Some("Debug Plugin"), Some("Debug marketplace plugin."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )], }, ])), ); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!("plugins_popup_newly_installed_marketplace", popup); assert!( popup.contains("Debug Marketplace installed successfully.") && popup.contains("ctrl + u upgrade") && popup.contains("ctrl + r remove") && popup.contains("Debug Plugin"), "expected marketplace add refresh to switch to the new marketplace tab, got:\n{popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); chat.add_plugins_output(); for _ in 0..3 { chat.handle_key_event(KeyEvent::from(KeyCode::Right)); } let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); assert!( reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.") && !reopened_popup.contains("installed successfully"), "expected reopening the marketplace tab later to use the normal header, got:\n{reopened_popup}" );}#[tokio::test]async fn plugins_popup_removes_user_configured_marketplace_flow() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.to_path_buf(); let temp = tempdir().expect("tempdir"); let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::default().with_user_config( &config_toml_path, toml::from_str::<TomlValue>( "[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n", ) .expect("marketplace config"), ); render_loaded_plugins_popup( &mut chat, plugins_test_response(vec![ plugins_test_curated_marketplace(Vec::new()), plugins_test_repo_marketplace(vec![plugins_test_summary( "plugin-debug", "debug", Some("Debug Plugin"), Some("Debug marketplace plugin."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )]), ]), ); 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); assert!( repo_tab.contains("Repo Marketplace.") && repo_tab.contains("ctrl + u upgrade") && repo_tab.contains("ctrl + r remove") && repo_tab.contains("Debug Plugin"), "expected removable user-configured marketplace tab, got:\n{repo_tab}" ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); let confirmation = render_bottom_popup(&chat, /*width*/ 100); assert!( confirmation.contains("Remove Repo Marketplace marketplace?") && confirmation.contains("Remove marketplace") && confirmation.contains("Back to plugins"), "expected marketplace removal confirmation, got:\n{confirmation}" ); assert_chatwidget_snapshot!( "plugins_popup_marketplace_remove_confirmation", confirmation ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let marketplace_display_name = match rx.try_recv() { Ok(AppEvent::OpenMarketplaceRemoveLoading { marketplace_display_name, }) => marketplace_display_name, other => panic!("expected OpenMarketplaceRemoveLoading event, got {other:?}"), }; assert_eq!(marketplace_display_name, "Repo Marketplace"); match rx.try_recv() { Ok(AppEvent::FetchMarketplaceRemove { cwd: event_cwd, marketplace_name, marketplace_display_name, }) => { assert_eq!(event_cwd, cwd); assert_eq!(marketplace_name, "repo"); assert_eq!(marketplace_display_name, "Repo Marketplace"); } other => panic!("expected FetchMarketplaceRemove event, got {other:?}"), } chat.open_marketplace_remove_loading_popup(&marketplace_display_name); let loading = render_bottom_popup(&chat, /*width*/ 100); assert!( loading.contains("Removing Repo Marketplace...") && loading.contains("Removing marketplace..."), "expected marketplace removal loading state, got:\n{loading}" ); chat.on_marketplace_remove_loaded( cwd.clone(), "repo".to_string(), marketplace_display_name, Ok(MarketplaceRemoveResponse { marketplace_name: "repo".to_string(), installed_root: Some(plugins_test_absolute_path("marketplaces/repo")), }), ); chat.on_plugins_loaded( cwd, Ok(plugins_test_response(vec![ plugins_test_curated_marketplace(Vec::new()), ])), ); let refreshed = render_bottom_popup(&chat, /*width*/ 100); assert!( refreshed.contains("Browse plugins from available marketplaces.") && !refreshed.contains("Repo Marketplace") && !refreshed.contains("Debug Plugin") && !refreshed.contains("ctrl + r remove"), "expected refreshed plugin list without removed marketplace, got:\n{refreshed}" );}#[tokio::test]async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { 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-figma", "figma", Some("Figma"), Some("Design handoff."), /*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("Turn Figma files into implementation context."), &["design-review", "extract-copy"], &[ (codex_app_server_protocol::HookEventName::PreToolUse, 1), (codex_app_server_protocol::HookEventName::Stop, 2), ], &["Figma", "Slack"], &["figma-mcp", "docs-mcp"], ), }), ); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!( "plugin_detail_popup_installable", strip_osc8_for_snapshot(&popup) );}#[tokio::test]async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { 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-figma", "figma", Some("Figma"), Some("Design handoff."), /*installed*/ true, /*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("Turn Figma files into implementation context."), &["design-review", "extract-copy"], &[ (codex_app_server_protocol::HookEventName::PreToolUse, 1), (codex_app_server_protocol::HookEventName::Stop, 2), ], &["Figma", "Slack"], &["figma-mcp", "docs-mcp"], ), }), ); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( !popup.contains("Data shared with this app is subject to the app's"), "expected installed plugin details to hide the disclosure line, got:\n{popup}" ); assert_chatwidget_snapshot!( "plugin_detail_popup_installed", strip_osc8_for_snapshot(&popup) );}#[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, share_url: None, 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, 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(), }, }), ); 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, 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(), }, }), ); 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; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-figma", "figma", Some("Figma"), Some("Design handoff."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]); 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(), Err("Failed to load plugin details.".to_string()), ); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!("plugin_detail_error_popup", popup);}#[tokio::test]async fn plugins_popup_refresh_preserves_selected_row_position() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-notion", "notion", Some("Notion"), Some("Workspace docs."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-slack", "slack", Some("Slack"), Some("Team chat."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]); render_loaded_plugins_popup(&mut chat, initial); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); let before = render_bottom_popup(&chat, /*width*/ 100); assert!( before.contains("› [-] Slack"), "expected Slack to be selected before refresh, got:\n{before}" ); let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-airtable", "airtable", Some("Airtable"), Some("Structured records."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-notion", "notion", Some("Notion"), Some("Workspace docs."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-slack", "slack", Some("Slack"), Some("Team chat."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]); let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, /*width*/ 100); assert!( after.contains("› [-] Notion"), "expected refresh to preserve the selected row position, got:\n{after}" ); assert!( after.contains("Airtable"), "expected refreshed popup to include the updated plugin list, got:\n{after}" ); assert!( after.contains("Slack"), "expected refreshed popup to include the updated plugin list, got:\n{after}" );}#[tokio::test]async fn plugins_popup_refreshes_installed_counts_after_install() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let initial = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-drive", "drive", Some("Drive"), Some("Document access."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]); let before = render_loaded_plugins_popup(&mut chat, initial); assert!( before.contains("Installed 1 of 2 available plugins."), "expected initial installed count before refresh, got:\n{before}" ); assert!( before.contains("Available"), "expected pre-install popup copy before refresh, got:\n{before}" ); let refreshed = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-drive", "drive", Some("Drive"), Some("Document access."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]); let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, /*width*/ 100); assert!( after.contains("Installed 2 of 2 available plugins."), "expected /plugins to refresh installed counts after install, got:\n{after}" ); assert!( after.contains("Installed Space to disable; Enter view details."), "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" );}#[tokio::test]async fn plugins_popup_space_toggles_installed_plugin_from_list() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let cwd = chat.config.cwd.to_path_buf(); render_loaded_plugins_popup( &mut chat, plugins_test_response(vec![plugins_test_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-drive", "drive", Some("Drive"), Some("Document access."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]), ); while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); match rx.try_recv() { Ok(AppEvent::SetPluginEnabled { cwd: event_cwd, plugin_id, enabled, }) => { assert_eq!(event_cwd, cwd); assert_eq!(plugin_id, "plugin-drive"); assert!(!enabled); } other => panic!("expected SetPluginEnabled event, got {other:?}"), } chat.on_plugin_enabled_set( cwd, "plugin-drive".to_string(), /*enabled*/ false, Ok(()), ); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( popup.contains("› [ ] Drive"), "expected selected plugin row to stay selected after refresh, got:\n{popup}" );}#[tokio::test]async fn plugins_popup_space_on_uninstalled_row_does_not_start_search() { let (mut chat, mut 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_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-drive", "drive", Some("Drive"), Some("Document access."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]), ); 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(), "did not expect Space on an uninstalled plugin to emit an event" ); assert_eq!(after, before);}#[tokio::test]async fn plugins_popup_space_with_active_search_does_not_toggle_installed_plugin() { let (mut chat, mut 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_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-drive", "drive", Some("Drive"), Some("Document access."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]), ); while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::from(KeyCode::Down)); type_plugins_search_query(&mut chat, "dr"); chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert!( rx.try_recv().is_err(), "did not expect Space with an active plugin search to emit a toggle event" );}#[tokio::test]async fn plugins_popup_search_filters_visible_rows_snapshot() { 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_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-slack", "slack", Some("Slack"), Some("Team chat."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-drive", "drive", Some("Drive"), Some("Document access."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]), ); type_plugins_search_query(&mut chat, "sla"); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!("plugins_popup_search_filtered", popup); assert!( !popup.contains("Calendar") && !popup.contains("Drive"), "expected search to leave only matching rows visible, got:\n{popup}" );}#[tokio::test]async fn plugins_popup_installed_tab_filters_rows_and_clears_search() { 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_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ true, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-slack", "slack", Some("Slack"), Some("Team chat."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]), ); type_plugins_search_query(&mut chat, "sla"); chat.handle_key_event(KeyEvent::from(KeyCode::Right)); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( popup.contains("Installed plugins.") && popup.contains("Showing 1 installed plugins."), "expected Installed tab header, got:\n{popup}" ); assert!( popup.contains("Calendar") && !popup.contains("Slack"), "expected Installed tab to show only installed plugins, got:\n{popup}" ); assert!( !popup.contains("sla"), "expected tab switch to clear search query, got:\n{popup}" );}#[tokio::test]async fn plugins_popup_openai_curated_tab_omits_marketplace_in_rows() { 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_curated_marketplace(vec![plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )]), plugins_test_repo_marketplace(vec![plugins_test_summary( "plugin-repo", "repo", Some("Repo Plugin"), Some("Repo-only plugin."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )]), ]), ); chat.handle_key_event(KeyEvent::from(KeyCode::Right)); chat.handle_key_event(KeyEvent::from(KeyCode::Right)); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( popup.contains("OpenAI Curated marketplace."), "expected OpenAI Curated tab header, got:\n{popup}" ); assert!( popup.contains("Calendar") && !popup.contains("Repo Plugin"), "expected OpenAI Curated tab to show only official marketplace plugins, got:\n{popup}" ); assert!( !popup.contains("ChatGPT Marketplace ·"), "expected marketplace-specific rows to omit marketplace labels, got:\n{popup}" );}#[tokio::test]async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let response = plugins_test_response(vec![ PluginMarketplaceEntry { name: "duplicate".to_string(), path: Some(plugins_test_absolute_path( "marketplaces/home/marketplace.json", )), interface: Some(MarketplaceInterface { display_name: Some("Duplicate Marketplace".to_string()), }), plugins: vec![plugins_test_summary( "plugin-home", "home", Some("Home Plugin"), Some("Home marketplace plugin."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )], }, PluginMarketplaceEntry { name: "duplicate".to_string(), path: Some(plugins_test_absolute_path( "marketplaces/repo/marketplace.json", )), interface: Some(MarketplaceInterface { display_name: Some("Duplicate Marketplace".to_string()), }), plugins: vec![plugins_test_summary( "plugin-repo", "repo", Some("Repo Plugin"), Some("Repo marketplace plugin."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, )], }, ]); let cwd = chat.config.cwd.to_path_buf(); chat.on_plugins_loaded(cwd.clone(), Ok(response.clone())); chat.add_plugins_output(); for _ in 0..4 { chat.handle_key_event(KeyEvent::from(KeyCode::Right)); } chat.on_plugins_loaded(cwd, Ok(response)); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( popup.contains("Duplicate Marketplace (2/2)."), "expected refresh to preserve the second duplicate marketplace tab, got:\n{popup}" ); assert!( popup.contains("Repo Plugin") && !popup.contains("Home Plugin"), "expected second duplicate marketplace rows after refresh, got:\n{popup}" );}#[tokio::test]async fn plugins_popup_search_no_matches_and_backspace_restores_results() { 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_curated_marketplace(vec![ plugins_test_summary( "plugin-calendar", "calendar", Some("Calendar"), Some("Schedule management."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), plugins_test_summary( "plugin-slack", "slack", Some("Slack"), Some("Team chat."), /*installed*/ false, /*enabled*/ true, PluginInstallPolicy::Available, ), ])]), ); type_plugins_search_query(&mut chat, "zzz"); let no_matches = render_bottom_popup(&chat, /*width*/ 100); assert!( no_matches.contains("zzz"), "expected popup to show the typed search query, got:\n{no_matches}" ); assert!( no_matches.contains("no matches"), "expected popup to render the no-matches UX, got:\n{no_matches}" ); for _ in 0..3 { chat.handle_key_event(KeyEvent::from(KeyCode::Backspace)); } let restored = render_bottom_popup(&chat, /*width*/ 100); assert!( restored.contains("Calendar") && restored.contains("Slack"), "expected clearing the query to restore the plugin rows, got:\n{restored}" ); assert!( !restored.contains("no matches"), "did not expect the no-matches state after clearing the query, got:\n{restored}" );}#[tokio::test]async fn apps_popup_stays_loading_until_final_snapshot_updates() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); let notion_id = "unit_test_apps_popup_refresh_connector_1"; let linear_id = "unit_test_apps_popup_refresh_connector_2"; chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: notion_id.to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ false, ); chat.add_connectors_output(); assert!( chat.connectors.prefetch_in_flight, "expected /apps to trigger a forced connectors refresh" ); let before = render_bottom_popup(&chat, /*width*/ 80); assert!( before.contains("Loading installed and available apps..."), "expected /apps to stay in the loading state until the full list arrives, got:\n{before}" ); assert_chatwidget_snapshot!("apps_popup_loading_state", before); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![ AppInfo { id: notion_id.to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: linear_id.to_string(), name: "Linear".to_string(), description: Some("Project tracking".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, ], }), /*is_final*/ true, ); let after = render_bottom_popup(&chat, /*width*/ 80); assert!( after.contains("Installed 2 of 2 available apps."), "expected refreshed apps popup snapshot, got:\n{after}" ); assert!( after.contains("Linear"), "expected refreshed popup to include new connector, got:\n{after}" );}#[tokio::test]async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.bottom_pane .set_composer_text("$".to_string(), Vec::new(), Vec::new()); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![ AppInfo { id: "google_drive".to_string(), name: "Google Drive".to_string(), description: Some("Connected files".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/google-drive".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: "arabica_uae".to_string(), name: "% Arabica UAE".to_string(), description: Some("Directory-only app".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/arabica".to_string()), is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), }, ], }), /*is_final*/ false, ); assert_matches!( &chat.connectors.partial_snapshot, Some(snapshot) if snapshot .connectors .iter() .find(|connector| connector.id == "arabica_uae") .is_some_and(|connector| !connector.is_accessible) ); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Google Drive"), "expected accessible apps to appear in the mention popup, got:\n{popup}" ); assert!( !popup.contains("% Arabica UAE"), "did not expect an inaccessible directory app in the mention popup, got:\n{popup}" );}#[tokio::test]async fn apps_refresh_failure_keeps_existing_full_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); let notion_id = "unit_test_apps_refresh_failure_connector_1"; let linear_id = "unit_test_apps_refresh_failure_connector_2"; let full_connectors = vec![ AppInfo { id: notion_id.to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: linear_id.to_string(), name: "Linear".to_string(), description: Some("Project tracking".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), }, ]; chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: full_connectors.clone(), }), /*is_final*/ true, ); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: notion_id.to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ false, ); chat.on_connectors_loaded( Err("failed to load apps".to_string()), /*is_final*/ true, ); assert_matches!( &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors ); chat.add_connectors_output(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Installed 1 of 2 available apps."), "expected previous full snapshot to be preserved, got:\n{popup}" );}#[tokio::test]async fn apps_popup_preserves_selected_app_across_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![ AppInfo { id: "notion".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: "slack".to_string(), name: "Slack".to_string(), description: Some("Team chat".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/slack".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, ], }), /*is_final*/ true, ); chat.add_connectors_output(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); let before = render_bottom_popup(&chat, /*width*/ 80); assert!( before.contains("› Slack"), "expected Slack to be selected before refresh, got:\n{before}" ); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![ AppInfo { id: "airtable".to_string(), name: "Airtable".to_string(), description: Some("Spreadsheets".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/airtable".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: "notion".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: "slack".to_string(), name: "Slack".to_string(), description: Some("Team chat".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/slack".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, ], }), /*is_final*/ true, ); let after = render_bottom_popup(&chat, /*width*/ 80); assert!( after.contains("› Slack"), "expected Slack to stay selected after refresh, got:\n{after}" ); assert!( !after.contains("› Notion"), "did not expect selection to reset to Notion after refresh, got:\n{after}" );}#[tokio::test]async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetch() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.connectors.prefetch_in_flight = true; chat.connectors.force_refetch_pending = true; let full_connectors = vec![AppInfo { id: "unit_test_apps_refresh_failure_pending_connector".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }]; chat.connectors.cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { connectors: full_connectors.clone(), }); chat.on_connectors_loaded( Err("failed to load apps".to_string()), /*is_final*/ true, ); assert!(chat.connectors.prefetch_in_flight); assert!(!chat.connectors.force_refetch_pending); assert_matches!( &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors );}#[tokio::test]async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); let full_connectors = vec![ AppInfo { id: "unit_test_connector_1".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: "unit_test_connector_2".to_string(), name: "Linear".to_string(), description: Some("Project tracking".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), }, ]; chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: full_connectors.clone(), }), /*is_final*/ true, ); chat.add_connectors_output(); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![ AppInfo { id: "unit_test_connector_1".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, AppInfo { id: "connector_openai_hidden".to_string(), name: "Hidden OpenAI".to_string(), description: Some("Should be filtered".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/hidden-openai".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }, ], }), /*is_final*/ false, ); assert_matches!( &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors ); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Installed 1 of 2 available apps."), "expected popup to keep the last full snapshot while partial refresh loads, got:\n{popup}" ); assert!( !popup.contains("Hidden OpenAI"), "expected popup to ignore partial refresh rows until the full list arrives, got:\n{popup}" );}#[tokio::test]async fn apps_refresh_failure_without_full_snapshot_falls_back_to_installed_apps() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: "unit_test_apps_refresh_failure_fallback_connector".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ false, ); chat.add_connectors_output(); let loading_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( loading_popup.contains("Loading installed and available apps..."), "expected /apps to keep showing loading before the final result, got:\n{loading_popup}" ); chat.on_connectors_loaded( Err("failed to load apps".to_string()), /*is_final*/ true, ); assert_matches!( &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors.len() == 1 ); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Installed 1 of 1 available apps."), "expected /apps to fall back to the installed apps snapshot, got:\n{popup}" ); assert!( popup.contains("Installed. Press Enter to open the app page"), "expected the fallback popup to behave like the installed apps view, got:\n{popup}" );}#[tokio::test]async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: "connector_1".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: false, plugin_display_names: Vec::new(), }], }), /*is_final*/ true, ); chat.add_connectors_output(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Installed · Disabled. Press Enter to open the app page"), "expected selected app description to include disabled status, got:\n{popup}" ); assert!( popup.contains("enable/disable this app."), "expected selected app description to mention enable/disable action, got:\n{popup}" );}#[tokio::test]async fn apps_refresh_preserves_toggled_enabled_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: "connector_1".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ true, ); chat.update_connector_enabled("connector_1", /*enabled*/ false); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: "connector_1".to_string(), name: "Notion".to_string(), description: Some("Workspace docs".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ true, ); assert_matches!( &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot .connectors .iter() .find(|connector| connector.id == "connector_1") .is_some_and(|connector| !connector.is_enabled) ); chat.add_connectors_output(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Installed · Disabled. Press Enter to open the app page"), "expected disabled status to persist after reload, got:\n{popup}" );}#[tokio::test]async fn apps_popup_for_not_installed_app_uses_install_only_selected_description() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: "connector_2".to_string(), name: "Linear".to_string(), description: Some("Project tracking".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ true, ); chat.add_connectors_output(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Can be installed. Press Enter to open the app page to install"), "expected selected app description to be install-only for not-installed apps, got:\n{popup}" ); assert!( !popup.contains("enable/disable this app."), "did not expect enable/disable text for not-installed apps, got:\n{popup}" );}#[tokio::test]async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let features = vec![ ExperimentalFeatureItem { feature: Feature::JsRepl, name: "JavaScript REPL".to_string(), description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(), enabled: false, }, ExperimentalFeatureItem { feature: Feature::ShellTool, name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), enabled: true, }, ]; let view = ExperimentalFeaturesView::new( features, chat.app_event_tx.clone(), crate::keymap::RuntimeKeymap::defaults().list, ); chat.bottom_pane.show_view(Box::new(view)); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("experimental_features_popup", popup);}#[tokio::test]async fn experimental_features_toggle_saves_on_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let expected_feature = Feature::JsRepl; let view = ExperimentalFeaturesView::new( vec![ExperimentalFeatureItem { feature: expected_feature, name: "JavaScript REPL".to_string(), description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities.".to_string(), enabled: false, }], chat.app_event_tx.clone(), crate::keymap::RuntimeKeymap::defaults().list, ); chat.bottom_pane.show_view(Box::new(view)); chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert!( rx.try_recv().is_err(), "expected no updates until saving the popup" ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let mut updates = None; while let Ok(event) = rx.try_recv() { if let AppEvent::UpdateFeatureFlags { updates: event_updates, } = event { updates = Some(event_updates); break; } } let updates = updates.expect("expected UpdateFeatureFlags event"); assert_eq!(updates, vec![(expected_feature, true)]);}#[tokio::test]async fn experimental_popup_omits_stable_guardian_approval() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let guardian_stage = FEATURES .iter() .find(|spec| spec.id == Feature::GuardianApproval) .map(|spec| spec.stage) .expect("expected guardian approval feature metadata"); assert_eq!(guardian_stage, Stage::Stable); chat.open_experimental_popup(); let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( !popup.contains("Auto-review"), "expected stable auto-review feature to be omitted from experimental popup, got:\n{popup}" );}#[tokio::test]async fn multi_agent_enable_prompt_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.open_multi_agent_enable_prompt(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("multi_agent_enable_prompt", popup);}#[tokio::test]async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.open_multi_agent_enable_prompt(); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!( rx.try_recv(), Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)] ); let cell = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => cell, other => panic!("expected InsertHistoryCell event, got {other:?}"), }; let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 120)); assert!(rendered.contains("Subagents will be enabled in the next session."));}#[tokio::test]async fn memories_enable_prompt_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ false); chat.open_memories_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("memories_enable_prompt", popup);}#[tokio::test]async fn memories_enable_prompt_updates_feature_without_notice() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ false); chat.open_memories_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!( rx.try_recv(), Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::MemoryTool, true)] ); assert!( rx.try_recv().is_err(), "memory enable prompt should not emit the success notice before persistence succeeds" );}#[tokio::test]async fn memories_settings_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); chat.config.memories.use_memories = true; chat.config.memories.generate_memories = false; chat.open_memories_popup(); let popup = strip_osc8_for_snapshot(&render_bottom_popup(&chat, /*width*/ 80)); assert_chatwidget_snapshot!("memories_settings_popup", popup);}#[tokio::test]async fn memories_reset_confirmation_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); chat.config.memories.use_memories = true; chat.config.memories.generate_memories = false; chat.open_memories_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("memories_reset_confirmation", popup);}#[tokio::test]async fn memories_settings_toggle_saves_on_enter() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); chat.config.memories.use_memories = true; chat.config.memories.generate_memories = false; chat.open_memories_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!( rx.try_recv(), Ok(AppEvent::UpdateMemorySettings { use_memories: true, generate_memories: true, }) );}#[tokio::test]async fn memories_reset_confirmation_sends_event_on_confirm() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); chat.config.memories.use_memories = true; chat.config.memories.generate_memories = false; chat.open_memories_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::ResetMemories));}#[tokio::test]async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); chat.open_model_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_selection_popup", popup);}#[tokio::test]async fn personality_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.thread_id = Some(ThreadId::new()); chat.open_personality_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("personality_selection_popup", popup);}#[tokio::test]async fn skills_menu_default_mentions_shortcut_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.open_skills_menu(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("skills_menu_default_mentions_shortcut", popup);}#[tokio::test]async fn model_picker_hides_show_in_picker_false_models_from_cache() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await; chat.thread_id = Some(ThreadId::new()); let preset = |slug: &str, show_in_picker: bool| ModelPreset { id: slug.to_string(), model: slug.to_string(), display_name: slug.to_string(), description: format!("{slug} description"), default_reasoning_effort: ReasoningEffortConfig::Medium, supported_reasoning_efforts: vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::Medium, description: "medium".to_string(), }], supports_personality: false, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, is_default: false, upgrade: None, show_in_picker, availability_nux: None, supported_in_api: true, input_modalities: default_input_modalities(), }; chat.open_model_popup_with_presets(vec![ preset("test-visible-model", true), preset("test-hidden-model", false), ]); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_picker_filters_hidden_models", popup); assert!( popup.contains("test-visible-model"), "expected visible model to appear in picker:\n{popup}" ); assert!( !popup.contains("test-hidden-model"), "expected hidden model to be excluded from picker:\n{popup}" );}#[tokio::test]async fn server_overloaded_error_does_not_switch_models() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.set_model("gpt-5.3-codex"); while rx.try_recv().is_ok() {} while op_rx.try_recv().is_ok() {} handle_error( &mut chat, "server overloaded", Some(CodexErrorInfo::ServerOverloaded), ); while let Ok(event) = rx.try_recv() { if let AppEvent::UpdateModel(model) = event { assert_eq!( model, "gpt-5.3-codex", "did not expect model switch on server-overloaded error" ); } } while let Ok(event) = op_rx.try_recv() { if let Op::OverrideTurnContext { model, .. } = event { assert!( model.is_none(), "did not expect OverrideTurnContext model update on server-overloaded error" ); } }}#[tokio::test]async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let mut preset = get_available_model(&chat, "gpt-5.4"); preset.supported_reasoning_efforts.insert( 2, ReasoningEffortPreset { effort: ReasoningEffortConfig::Custom("max".to_string()), description: "Maximum available reasoning".to_string(), }, ); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_reasoning_selection_popup", popup);}#[tokio::test]async fn model_reasoning_selection_popup_applies_custom_effort() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let mut preset = get_available_model(&chat, "gpt-5.4"); preset .supported_reasoning_efforts .push(ReasoningEffortPreset { effort: custom_effort.clone(), description: "Maximum available reasoning".to_string(), }); chat.open_reasoning_popup(preset); while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let selected_effort_events = std::iter::from_fn(|| rx.try_recv().ok()) .filter_map(|event| match event { AppEvent::UpdateReasoningEffort(effort) => Some((None, effort)), AppEvent::PersistModelSelection { model, effort } => Some((Some(model), effort)), _ => None, }) .collect::<Vec<_>>(); assert_eq!( selected_effort_events, vec![ (None, Some(custom_effort.clone())), (Some("gpt-5.4".to_string()), Some(custom_effort)), ] );}#[tokio::test]async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.2"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup);}async fn assert_reasoning_shortcuts_update_effort( key_events: [KeyEvent; 2], expected_effort: ReasoningEffortConfig, expect_model_update: bool,) { for key_event in key_events { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); chat.handle_key_event(key_event); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); if expect_model_update { assert!( events.iter().any( |event| matches!(event, AppEvent::UpdateModel(model) if model == "gpt-5.4") ), "expected model update event for {key_event:?}; events: {events:?}" ); } assert!( events.iter().any(|event| matches!( event, AppEvent::UpdateReasoningEffort(Some(effort)) if effort == &expected_effort )), "expected reasoning update event for {key_event:?}; events: {events:?}" ); assert!( events .iter() .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })), "expected no model persistence event for {key_event:?}; events: {events:?}" ); }}#[tokio::test]async fn reasoning_up_shortcuts_raise_reasoning_effort() { assert_reasoning_shortcuts_update_effort( [ KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT), KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), ], ReasoningEffortConfig::High, /*expect_model_update*/ true, ) .await;}#[tokio::test]async fn reasoning_down_shortcuts_lower_reasoning_effort() { assert_reasoning_shortcuts_update_effort( [ KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT), KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), ], ReasoningEffortConfig::Low, /*expect_model_update*/ false, ) .await;}#[tokio::test]async fn reasoning_shortcut_clears_armed_quit_shortcut() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); chat.arm_quit_shortcut(key_hint::ctrl(KeyCode::Char('c'))); chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT)); assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); assert!(chat.quit_shortcut_expires_at.is_none()); assert!(chat.quit_shortcut_key.is_none()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events .iter() .all(|event| !matches!(event, AppEvent::Exit(_))), "did not expect reasoning shortcut to quit; events: {events:?}" );}#[tokio::test]async fn reasoning_shortcut_is_ignored_with_model_popup_open() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); chat.open_model_popup(); chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT)); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events .iter() .all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))), "did not expect reasoning update while popup is active; events: {events:?}" ); assert!( events .iter() .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })), "did not expect model persistence while popup is active; events: {events:?}" );}#[tokio::test]async fn reasoning_popup_shows_extra_high_with_space() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_chatgpt_auth(&mut chat); let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( popup.contains("Extra high"), "expected popup to include 'Extra high'; popup: {popup}" ); assert!( !popup.contains("Extrahigh"), "expected popup not to include 'Extrahigh'; popup: {popup}" );}#[tokio::test]async fn single_reasoning_option_skips_selection() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, description: "Greater reasoning depth for complex or ambiguous problems".to_string(), }]; let preset = ModelPreset { id: "model-with-single-reasoning".to_string(), model: "model-with-single-reasoning".to_string(), display_name: "model-with-single-reasoning".to_string(), description: "".to_string(), default_reasoning_effort: ReasoningEffortConfig::High, supported_reasoning_efforts: single_effort, supports_personality: false, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), default_service_tier: None, is_default: false, upgrade: None, show_in_picker: true, availability_nux: None, supported_in_api: true, input_modalities: default_input_modalities(), }; chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( !popup.contains("Select Reasoning Level"), "expected reasoning selection popup to be skipped" ); let mut events = Vec::new(); while let Ok(ev) = rx.try_recv() { events.push(ev); } assert!( events .iter() .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), "expected reasoning effort to be applied automatically; events: {events:?}" );}#[tokio::test]async fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Open the feedback category selection popup via slash command. chat.dispatch_command(SlashCommand::Feedback); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("feedback_selection_popup", popup);}#[tokio::test]async fn feedback_upload_consent_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params( chat.app_event_tx.clone(), crate::app_event::FeedbackCategory::Bug, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), /*include_windows_sandbox_log*/ true, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), details: vec!["HTTPS_PROXY = hello".to_string()], }]), )); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("feedback_upload_consent_popup", popup);}#[tokio::test]async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_filename() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_selection_view(crate::bottom_pane::feedback_upload_consent_params( chat.app_event_tx.clone(), crate::app_event::FeedbackCategory::GoodResult, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), /*include_windows_sandbox_log*/ false, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), details: vec!["HTTPS_PROXY = hello".to_string()], }]), )); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("feedback_good_result_consent_popup", popup);}#[tokio::test]async fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.4"); chat.open_reasoning_popup(preset); let before_escape = render_bottom_popup(&chat, /*width*/ 80); assert!(before_escape.contains("Select Reasoning Level")); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let after_escape = render_bottom_popup(&chat, /*width*/ 80); assert!(after_escape.contains("Select Model")); assert!(!after_escape.contains("Select Reasoning Level"));}