Simplify TUI startup test coverage (#22573)

## Why

The TUI startup test surface had drifted into expensive, brittle
coverage:

- `tui/tests/suite/no_panic_on_startup.rs` was already ignored as flaky
while still spawning a PTY to exercise malformed exec-policy rules.
- `tui/tests/suite/model_availability_nux.rs` used a seeded session,
cursor-query spoofing, and repeated interrupts to verify a narrow
resume-path invariant.
- `app/tests.rs` had started accumulating unrelated startup and summary
coverage in one flat module even after the surrounding app code was
split into feature modules.

This keeps those behaviors covered while making the tests cheaper to
understand and less likely to rot. It also preserves the malformed-rules
regression from #8803 without requiring a terminal orchestration test.

## What changed

- Replaced the malformed `rules` startup PTY case with a direct
exec-policy loader regression:

[`rules_path_file_returns_read_dir_error`](21b6b5622f/codex-rs/core/src/exec_policy_tests.rs (L264-L284))
- Made the existing fresh-session-only startup tooltip behavior explicit
with

[`should_prepare_startup_tooltip_override`](21b6b5622f/codex-rs/tui/src/app/thread_routing.rs (L1272-L1279)),
then added focused coverage for the resume/fork gate and the persisted
NUX counter.
- Split startup and session-summary coverage out of
`tui/src/app/tests.rs` into dedicated modules so the test layout better
mirrors the current app architecture.
- Converted one single-message goal validation snapshot into semantic
assertions where layout was not the behavior under test.
- Removed the two PTY-heavy suite files that the narrower tests now
supersede.

## Verification

- `cargo test -p codex-core rules_path_file_returns_read_dir_error`
- `cargo test -p codex-tui startup_`
- `cargo test -p codex-tui session_summary_`
- `cargo test -p codex-tui
goal_slash_command_rejects_oversized_objective`
This commit is contained in:
Eric Traut
2026-05-13 18:16:54 -07:00
committed by GitHub
parent 4e368aa2e9
commit 35451ba79c
14 changed files with 373 additions and 630 deletions

View File

@@ -755,12 +755,15 @@ impl App {
&initial_prompt,
&initial_images,
);
let startup_tooltip_override =
if Self::should_prepare_startup_tooltip_override(&session_selection) {
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run).await
} else {
None
};
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
let started = app_server.start_thread(&config).await?;
let startup_tooltip_override =
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run)
.await;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
environment_manager: environment_manager.clone(),

View File

@@ -1,6 +1,8 @@
//! App-level orchestration tests for the TUI.
mod model_catalog;
mod session_summary;
mod startup;
use super::*;
use crate::app_backtrack::BacktrackSelection;
@@ -127,183 +129,6 @@ async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
assert_eq!(app.transcript_cells.len(), 0);
}
#[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::StartFresh),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Exit),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
}
)),
false
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
}
)),
false
);
}
#[test]
fn startup_paused_goal_prompt_gate_is_only_for_quiet_resume() {
let resume = SessionSelection::Resume(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
});
let fork = SessionSelection::Fork(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
});
let no_images: Vec<PathBuf> = Vec::new();
let initial_images = vec![PathBuf::from("/tmp/image.png")];
assert!(App::should_prompt_for_paused_goal_after_startup_resume(
&resume, &None, &no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&resume,
&Some("continue from here".to_string()),
&no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&resume,
&None,
&initial_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&SessionSelection::StartFresh,
&None,
&no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&fork, &None, &no_images
));
}
#[test]
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
let mut wait_for_initial_session =
App::should_wait_for_initial_session(&SessionSelection::StartFresh);
assert_eq!(wait_for_initial_session, true);
assert_eq!(
App::should_handle_active_thread_events(
wait_for_initial_session,
/*has_active_thread_receiver*/ true
),
false
);
assert_eq!(
App::should_stop_waiting_for_initial_session(
wait_for_initial_session,
/*primary_thread_id*/ None
),
false
);
if App::should_stop_waiting_for_initial_session(wait_for_initial_session, Some(ThreadId::new()))
{
wait_for_initial_session = false;
}
assert_eq!(wait_for_initial_session, false);
assert_eq!(
App::should_handle_active_thread_events(
wait_for_initial_session,
/*has_active_thread_receiver*/ true
),
true
);
}
#[test]
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(
wait_for_resume,
/*has_active_thread_receiver*/ true
),
true
);
let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(
wait_for_fork,
/*has_active_thread_receiver*/ true
),
true
);
}
#[tokio::test]
async fn ignore_same_thread_resume_reports_noop_for_current_thread() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(THREAD_EVENT_CHANNEL_CAPACITY, session, Vec::new()),
);
app.activate_thread_channel(thread_id).await;
while app_event_rx.try_recv().is_ok() {}
let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
});
assert!(ignored);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected info message after same-thread resume, saw {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert!(rendered.contains(&format!(
"Already viewing {}.",
test_path_display("/tmp/project")
)));
}
#[tokio::test]
async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session);
let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
});
assert!(!ignored);
assert!(app.transcript_cells.is_empty());
}
#[test]
fn bypass_hook_trust_startup_warning_snapshot() {
let rendered = lines_to_single_string(
@@ -5417,90 +5242,3 @@ async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() {
assert!(!app.chat_widget.should_handle_vim_insert_escape(esc));
assert!(app.should_handle_backtrack_esc(esc));
}
#[tokio::test]
async fn session_summary_skips_when_no_usage_or_resume_hint() {
assert!(
session_summary(
TokenUsage::default(),
/*thread_id*/ None,
/*thread_name*/ None,
/*rollout_path*/ None,
)
.is_none()
);
}
#[tokio::test]
async fn session_summary_skips_resume_hint_until_rollout_exists() {
let usage = TokenUsage::default();
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
assert!(
session_summary(
usage,
Some(conversation),
/*thread_name*/ None,
Some(&rollout_path),
)
.is_none()
);
}
#[tokio::test]
async fn session_summary_includes_resume_hint_for_persisted_rollout() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\n").expect("write rollout");
let summary = session_summary(
usage,
Some(conversation),
/*thread_name*/ None,
Some(&rollout_path),
)
.expect("summary");
assert_eq!(
summary.usage_line,
Some("Token usage: total=12 input=10 output=2".to_string())
);
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}
#[tokio::test]
async fn session_summary_uses_id_even_when_thread_has_name() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\n").expect("write rollout");
let summary = session_summary(
usage,
Some(conversation),
Some("my-session".to_string()),
Some(&rollout_path),
)
.expect("summary");
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}

View File

@@ -174,6 +174,46 @@ fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() {
assert_eq!(selected, None);
}
#[tokio::test]
async fn prepare_startup_tooltip_override_persists_model_availability_nux_count() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
let mut presets = all_model_presets();
presets.iter_mut().for_each(|preset| {
preset.availability_nux = None;
});
let target = presets
.iter_mut()
.find(|preset| preset.model == "gpt-5.4")
.expect("target preset present");
target.availability_nux = Some(ModelAvailabilityNux {
message: "gpt-5.4 is available".to_string(),
});
let tooltip =
prepare_startup_tooltip_override(&mut config, &presets, /*is_first_run*/ false).await;
assert_eq!(tooltip.as_deref(), Some("gpt-5.4 is available"));
assert_eq!(
config.model_availability_nux.shown_count,
HashMap::from([("gpt-5.4".to_string(), 1)])
);
let reloaded = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("reloaded config");
assert_eq!(
reloaded.model_availability_nux.shown_count,
HashMap::from([("gpt-5.4".to_string(), 1)])
);
}
#[tokio::test]
async fn accepted_model_migration_persists_target_default_reasoning_effort() {
let codex_home = tempdir().expect("temp codex home");

View File

@@ -0,0 +1,89 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn session_summary_skips_when_no_usage_or_resume_hint() {
assert!(
session_summary(
TokenUsage::default(),
/*thread_id*/ None,
/*thread_name*/ None,
/*rollout_path*/ None,
)
.is_none()
);
}
#[tokio::test]
async fn session_summary_skips_resume_hint_until_rollout_exists() {
let usage = TokenUsage::default();
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
assert!(
session_summary(
usage,
Some(conversation),
/*thread_name*/ None,
Some(&rollout_path),
)
.is_none()
);
}
#[tokio::test]
async fn session_summary_includes_resume_hint_for_persisted_rollout() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\n").expect("write rollout");
let summary = session_summary(
usage,
Some(conversation),
/*thread_name*/ None,
Some(&rollout_path),
)
.expect("summary");
assert_eq!(
summary.usage_line,
Some("Token usage: total=12 input=10 output=2".to_string())
);
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}
#[tokio::test]
async fn session_summary_uses_id_even_when_thread_has_name() {
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
..Default::default()
};
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let temp_dir = tempdir().expect("temp dir");
let rollout_path = temp_dir.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "{}\n").expect("write rollout");
let summary = session_summary(
usage,
Some(conversation),
Some("my-session".to_string()),
Some(&rollout_path),
)
.expect("summary");
assert_eq!(
summary.resume_command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}

View File

@@ -0,0 +1,201 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::StartFresh),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Exit),
true
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
}
)),
false
);
assert_eq!(
App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
}
)),
false
);
}
#[test]
fn startup_tooltip_override_is_only_prepared_for_fresh_or_exit_session_selection() {
assert!(App::should_prepare_startup_tooltip_override(
&SessionSelection::StartFresh
));
assert!(App::should_prepare_startup_tooltip_override(
&SessionSelection::Exit
));
assert!(!App::should_prepare_startup_tooltip_override(
&SessionSelection::Resume(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
})
));
assert!(!App::should_prepare_startup_tooltip_override(
&SessionSelection::Fork(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
})
));
}
#[test]
fn startup_paused_goal_prompt_gate_is_only_for_quiet_resume() {
let resume = SessionSelection::Resume(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
});
let fork = SessionSelection::Fork(crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
});
let no_images: Vec<PathBuf> = Vec::new();
let initial_images = vec![PathBuf::from("/tmp/image.png")];
assert!(App::should_prompt_for_paused_goal_after_startup_resume(
&resume, &None, &no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&resume,
&Some("continue from here".to_string()),
&no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&resume,
&None,
&initial_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&SessionSelection::StartFresh,
&None,
&no_images
));
assert!(!App::should_prompt_for_paused_goal_after_startup_resume(
&fork, &None, &no_images
));
}
#[test]
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
let mut wait_for_initial_session =
App::should_wait_for_initial_session(&SessionSelection::StartFresh);
assert_eq!(wait_for_initial_session, true);
assert_eq!(
App::should_handle_active_thread_events(
wait_for_initial_session,
/*has_active_thread_receiver*/ true
),
false
);
assert_eq!(
App::should_stop_waiting_for_initial_session(
wait_for_initial_session,
/*primary_thread_id*/ None
),
false
);
if App::should_stop_waiting_for_initial_session(wait_for_initial_session, Some(ThreadId::new()))
{
wait_for_initial_session = false;
}
assert_eq!(wait_for_initial_session, false);
assert_eq!(
App::should_handle_active_thread_events(
wait_for_initial_session,
/*has_active_thread_receiver*/ true
),
true
);
}
#[test]
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/restore")),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(
wait_for_resume,
/*has_active_thread_receiver*/ true
),
true
);
let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork(
crate::resume_picker::SessionTarget {
path: Some(PathBuf::from("/tmp/fork")),
thread_id: ThreadId::new(),
},
));
assert_eq!(
App::should_handle_active_thread_events(
wait_for_fork,
/*has_active_thread_receiver*/ true
),
true
);
}
#[tokio::test]
async fn ignore_same_thread_resume_reports_noop_for_current_thread() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(THREAD_EVENT_CHANNEL_CAPACITY, session, Vec::new()),
);
app.activate_thread_channel(thread_id).await;
while app_event_rx.try_recv().is_ok() {}
let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
});
assert!(ignored);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected info message after same-thread resume, saw {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert!(rendered.contains(&format!(
"Already viewing {}.",
test_path_display("/tmp/project")
)));
}
#[tokio::test]
async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session);
let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
});
assert!(!ignored);
assert!(app.transcript_cells.is_empty());
}

View File

@@ -1269,6 +1269,15 @@ impl App {
)
}
pub(super) fn should_prepare_startup_tooltip_override(
session_selection: &SessionSelection,
) -> bool {
matches!(
session_selection,
SessionSelection::StartFresh | SessionSelection::Exit
)
}
pub(super) fn should_prompt_for_paused_goal_after_startup_resume(
session_selection: &SessionSelection,
initial_prompt: &Option<String>,

View File

@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests/goal_validation.rs
expression: rendered
---
■ Goal objective is too long: 4,001 characters. Limit: 4,000 characters. Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md.

View File

@@ -120,7 +120,12 @@ async fn goal_slash_command_rejects_oversized_objective() {
"oversized goal should not emit a SetThreadGoalObjective event: {events:?}"
);
let rendered = rendered_insert_history(&events);
assert_chatwidget_snapshot!("goal_slash_command_oversized_objective_error", rendered);
assert!(rendered.contains("Goal objective is too long"));
assert!(rendered.contains("Put longer instructions in a file"));
assert!(
!rendered.contains("Message exceeds the maximum length"),
"expected goal-specific length error, got {rendered:?}"
);
assert_no_submit_op(&mut op_rx);
}