mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow, and #22433 completed phase 3 by extracting protocol, replay, streaming, and tool lifecycle handling. This PR is phase 4. It keeps moving high-churn UI coordination out of the central widget by extracting settings, popups, and status surfaces without changing the visible behavior those flows already provide. This is once again a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for runtime settings/model coordination, model/reasoning/collaboration popups, settings/personality/theme/audio/experimental popups, permission prompts, status setup/output controls, and Windows sandbox prompt flows. - Moved the remaining rate-limit nudge/status helpers and connectors popup/loading/update helpers into their existing focused modules. - Preserved the existing picker flows, approval behavior, status/title setup previews, rate-limit notices, and connectors/app list behavior while shrinking `chatwidget.rs` back toward orchestration. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and composition surface for these extracted behaviors. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. Completed in #22433. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. This PR. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer. ## Verification - `cargo check -p codex-tui` - `cargo test -p codex-tui chatwidget::tests::permissions` - `cargo test -p codex-tui chatwidget::tests::status_surface_previews` - `cargo test -p codex-tui chatwidget::tests::popups_and_settings` - `cargo test -p codex-tui chatwidget::tests::status_and_layout` `cargo test -p codex-tui` also compiles and begins running, but aborts in the unchanged app-side test `app::tests::discard_side_thread_keeps_local_state_when_server_close_fails` with a reproducible stack overflow.
2793 lines
100 KiB
Rust
2793 lines
100 KiB
Rust
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_features::Stage;
|
||
use pretty_assertions::assert_eq;
|
||
|
||
#[tokio::test]
|
||
async fn realtime_error_closes_without_followup_closed_info() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||
|
||
chat.on_realtime_error(ThreadRealtimeErrorNotification {
|
||
thread_id: ThreadId::new().to_string(),
|
||
message: "boom".to_string(),
|
||
});
|
||
next_realtime_close_op(&mut op_rx);
|
||
|
||
chat.on_realtime_conversation_closed(ThreadRealtimeClosedNotification {
|
||
thread_id: ThreadId::new().to_string(),
|
||
reason: Some("error".to_string()),
|
||
});
|
||
|
||
let rendered = drain_insert_history(&mut rx)
|
||
.into_iter()
|
||
.map(|lines| lines_to_single_string(&lines))
|
||
.collect::<Vec<_>>();
|
||
insta::assert_snapshot!(rendered.join("\n\n"), @"■ Realtime voice error: boom");
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[tokio::test]
|
||
async fn deleted_realtime_meter_uses_shared_stop_path() {
|
||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||
let placeholder_id = chat.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
|
||
chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
|
||
|
||
assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id));
|
||
|
||
next_realtime_close_op(&mut op_rx);
|
||
assert_eq!(chat.realtime_conversation.meter_placeholder_id, None);
|
||
assert_eq!(
|
||
chat.realtime_conversation.phase,
|
||
RealtimeConversationPhase::Stopping
|
||
);
|
||
}
|
||
|
||
#[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 = crate::legacy_core::test_support::get_model_offline(cfg.model.as_deref());
|
||
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
|
||
let init = ChatWidgetInit {
|
||
config: cfg.clone(),
|
||
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||
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,
|
||
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_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
|
||
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", true), ("Slack", false)],
|
||
&["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", true), ("Slack", false)],
|
||
&["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 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_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_initial_load_applies_enabled_state_from_config() {
|
||
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 temp = tempdir().expect("tempdir");
|
||
let config_toml_path = temp.path().join("config.toml").abs();
|
||
let user_config = toml::from_str::<TomlValue>(
|
||
"[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n",
|
||
)
|
||
.expect("apps config");
|
||
chat.config.config_layer_stack = chat
|
||
.config
|
||
.config_layer_stack
|
||
.with_user_config(&config_toml_path, user_config);
|
||
|
||
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)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_override() {
|
||
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 requirements = ConfigRequirementsToml {
|
||
apps: Some(AppsRequirementsToml {
|
||
apps: BTreeMap::from([(
|
||
"connector_1".to_string(),
|
||
AppRequirementToml {
|
||
enabled: Some(false),
|
||
tools: None,
|
||
},
|
||
)]),
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let temp = tempdir().expect("tempdir");
|
||
let config_toml_path = temp.path().join("config.toml").abs();
|
||
chat.config.config_layer_stack =
|
||
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
|
||
.expect("requirements stack")
|
||
.with_user_config(
|
||
&config_toml_path,
|
||
toml::from_str::<TomlValue>(
|
||
"[apps.connector_1]\nenabled = true\ndisabled_reason = \"user\"\n",
|
||
)
|
||
.expect("apps config"),
|
||
);
|
||
|
||
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 requirements-disabled connector to render as disabled, got:\n{popup}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_entry() {
|
||
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 requirements = ConfigRequirementsToml {
|
||
apps: Some(AppsRequirementsToml {
|
||
apps: BTreeMap::from([(
|
||
"connector_1".to_string(),
|
||
AppRequirementToml {
|
||
enabled: Some(false),
|
||
tools: None,
|
||
},
|
||
)]),
|
||
}),
|
||
..Default::default()
|
||
};
|
||
chat.config.config_layer_stack =
|
||
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
|
||
.expect("requirements stack");
|
||
|
||
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 requirements-disabled connector to render as disabled, 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 = 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);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[tokio::test]
|
||
async fn realtime_audio_selection_popup_snapshot() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||
chat.open_realtime_audio_popup();
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_chatwidget_snapshot!("realtime_audio_selection_popup", popup);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[tokio::test]
|
||
async fn realtime_audio_selection_popup_narrow_snapshot() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||
chat.open_realtime_audio_popup();
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 56);
|
||
assert_chatwidget_snapshot!("realtime_audio_selection_popup_narrow", popup);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[tokio::test]
|
||
async fn realtime_microphone_picker_popup_snapshot() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||
chat.config.realtime_audio.microphone = Some("Studio Mic".to_string());
|
||
chat.open_realtime_audio_device_selection_with_names(
|
||
RealtimeAudioDeviceKind::Microphone,
|
||
vec!["Built-in Mic".to_string(), "USB Mic".to_string()],
|
||
);
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_chatwidget_snapshot!("realtime_microphone_picker_popup", popup);
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
#[tokio::test]
|
||
async fn realtime_audio_picker_emits_persist_event() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||
chat.open_realtime_audio_device_selection_with_names(
|
||
RealtimeAudioDeviceKind::Speaker,
|
||
vec!["Desk Speakers".to_string(), "Headphones".to_string()],
|
||
);
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::PersistRealtimeAudioDeviceSelection {
|
||
kind: RealtimeAudioDeviceKind::Speaker,
|
||
name: Some(name),
|
||
}) if name == "Headphones"
|
||
);
|
||
}
|
||
|
||
#[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(),
|
||
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 preset = get_available_model(&chat, "gpt-5.4");
|
||
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_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);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn alt_period_raises_reasoning_effort() {
|
||
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(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT));
|
||
|
||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||
assert!(
|
||
events
|
||
.iter()
|
||
.any(|event| matches!(event, AppEvent::UpdateModel(model) if model == "gpt-5.4")),
|
||
"expected model update event; events: {events:?}"
|
||
);
|
||
assert!(
|
||
events.iter().any(|event| matches!(
|
||
event,
|
||
AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::High))
|
||
)),
|
||
"expected reasoning update event; events: {events:?}"
|
||
);
|
||
assert!(
|
||
events
|
||
.iter()
|
||
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
|
||
"expected no model persistence event; events: {events:?}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn alt_comma_lowers_reasoning_effort() {
|
||
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(KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT));
|
||
|
||
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
|
||
assert!(
|
||
events.iter().any(|event| matches!(
|
||
event,
|
||
AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Low))
|
||
)),
|
||
"expected reasoning update event; events: {events:?}"
|
||
);
|
||
assert!(
|
||
events
|
||
.iter()
|
||
.all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
|
||
"expected no model persistence event; events: {events:?}"
|
||
);
|
||
}
|
||
|
||
#[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(),
|
||
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()),
|
||
&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()),
|
||
&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"));
|
||
}
|