Files
codex/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs
pakrym-oai 413c1e1fdf [codex] reduce module visibility (#16978)
## Summary
- reduce public module visibility across Rust crates, preferring private
or crate-private modules with explicit crate-root public exports
- update external call sites and tests to use the intended public crate
APIs instead of reaching through module trees
- add the module visibility guideline to AGENTS.md

## Validation
- `cargo check --workspace --all-targets --message-format=short` passed
before the final fix/format pass
- `just fix` completed successfully
- `just fmt` completed successfully
- `git diff --check` passed
2026-04-07 08:03:35 -07:00

1812 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
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_conversation_realtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error("boom".to_string()),
});
next_realtime_close_op(&mut op_rx);
chat.on_realtime_conversation_closed(RealtimeConversationClosedEvent {
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 = codex_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(),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
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,
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 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 mut 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,
)]),
]);
response.remote_sync_error = Some("remote sync timed out".to_string());
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 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"],
&[("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"],
&[("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 plugins_popup_refresh_replaces_selection_with_first_row() {
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(" Airtable"),
"expected refresh to rebuild the popup from the new first row, 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 Press Enter to view plugin details."),
"expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}"
);
}
#[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_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![codex_chatgpt::connectors::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![
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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![
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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![codex_chatgpt::connectors::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![
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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![
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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![codex_chatgpt::connectors::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![
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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![
codex_chatgpt::connectors::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(),
},
codex_chatgpt::connectors::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![codex_chatgpt::connectors::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![codex_chatgpt::connectors::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![codex_chatgpt::connectors::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),
},
)]),
}),
..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![codex_chatgpt::connectors::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),
},
)]),
}),
..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![codex_chatgpt::connectors::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![codex_chatgpt::connectors::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![codex_chatgpt::connectors::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![codex_chatgpt::connectors::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::GhostCommit,
name: "Ghost snapshots".to_string(),
description: "Capture undo snapshots each turn.".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());
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::GhostCommit;
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
feature: expected_feature,
name: "Ghost snapshots".to_string(),
description: "Capture undo snapshots each turn.".to_string(),
enabled: false,
}],
chat.app_event_tx.clone(),
);
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_shows_js_repl_node_requirement() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let js_repl_description = FEATURES
.iter()
.find(|spec| spec.id == Feature::JsRepl)
.and_then(|spec| spec.stage.experimental_menu_description())
.expect("expected js_repl experimental description");
let node_requirement = js_repl_description
.split(". ")
.find(|sentence| sentence.starts_with("Requires Node >= v"))
.map(|sentence| sentence.trim_end_matches(" installed."))
.expect("expected js_repl description to mention the Node requirement");
chat.open_experimental_popup();
let popup = render_bottom_popup(&chat, /*width*/ 120);
assert!(
popup.contains(node_requirement),
"expected js_repl feature description to mention the required Node version, got:\n{popup}"
);
}
#[tokio::test]
async fn experimental_popup_includes_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");
let guardian_name = guardian_stage
.experimental_menu_name()
.expect("expected guardian approval experimental menu name");
let guardian_description = guardian_stage
.experimental_menu_description()
.expect("expected guardian approval experimental description");
chat.open_experimental_popup();
let popup = render_bottom_popup(&chat, /*width*/ 120);
let normalized_popup = popup.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
popup.contains(guardian_name),
"expected guardian approvals entry in experimental popup, got:\n{popup}"
);
assert!(
normalized_popup.contains(guardian_description),
"expected guardian approvals description in 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 model_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).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.2-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.2-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.2-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.2-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.2-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,
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.2-codex")).await;
chat.set_model("gpt-5.2-codex");
while rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
chat.handle_codex_event(Event {
id: "err-1".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "server overloaded".to_string(),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
}),
});
while let Ok(event) = rx.try_recv() {
if let AppEvent::UpdateModel(model) = event {
assert_eq!(
model, "gpt-5.2-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.1-codex-max")).await;
set_chatgpt_auth(&mut chat);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
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.1-codex-max")).await;
set_chatgpt_auth(&mut chat);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
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 reasoning_popup_shows_extra_high_with_space() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
set_chatgpt_auth(&mut chat);
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
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,
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(),
&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(),
&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.1-codex-max")).await;
chat.thread_id = Some(ThreadId::new());
chat.open_model_popup();
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
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"));
}