use std::path::Path;use std::path::PathBuf;use std::process::Command;use std::process::Output;use std::thread::sleep;use std::time::Duration;use std::time::Instant;use anyhow::Context;use anyhow::Result;use core_test_support::responses;use core_test_support::skip_if_no_network;use tempfile::tempdir;use wiremock::MockServer;#[tokio::test(flavor = "multi_thread", worker_threads = 2)]#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]async fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> { if cfg!(windows) { return Ok(()); } skip_if_no_network!(Ok(())); if Command::new("tmux").arg("-V").output().is_err() { eprintln!("skipping resize smoke because tmux is unavailable"); return Ok(()); } let repo_root = codex_utils_cargo_bin::repo_root()?; let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); write_config(codex_home.path(), &repo_root)?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-reflow-smoke-{}", std::process::id()); let _session = TmuxSession { name: session_name.clone(), }; let prompt = "Say hi."; let start_output = checked_output( Command::new("tmux") .arg("new-session") .arg("-d") .arg("-P") .arg("-F") .arg("#{pane_id}") .arg("-x") .arg("120") .arg("-y") .arg("40") .arg("-s") .arg(&session_name) .arg("--") .arg("env") .arg(format!("CODEX_HOME={}", codex_home.path().display())) .arg("OPENAI_API_KEY=dummy") .arg(codex) .arg("-c") .arg("analytics.enabled=false") .arg("-c") .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) .arg(prompt), )?; let codex_pane = stdout_text(&start_output).trim().to_string(); anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); wait_for_capture_contains( &codex_pane, "resize reflow sentinel", Duration::from_secs(/*secs*/ 15), )?; wait_for_capture_contains( &codex_pane, "gpt-5.4 default", Duration::from_secs(/*secs*/ 15), )?; let draft = "Notice where we are here in terms of y location."; check( Command::new("tmux") .arg("send-keys") .arg("-t") .arg(&codex_pane) .arg("-l") .arg(draft), )?; let baseline_capture = wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") .context("history row before split")?; let split_output = checked_output( Command::new("tmux") .arg("split-window") .arg("-d") .arg("-P") .arg("-F") .arg("#{pane_id}") .arg("-v") .arg("-l") .arg("12") .arg("-t") .arg(&codex_pane) .arg("sleep") .arg("30"), )?; let split_pane = stdout_text(&split_output).trim().to_string(); sleep(Duration::from_millis(/*millis*/ 250)); let first_capture = capture_pane(&codex_pane)?; let first_row = last_composer_row(&first_capture).context("composer row after split")?; sleep(Duration::from_millis(/*millis*/ 1_000)); let second_capture = capture_pane(&codex_pane)?; let second_row = last_composer_row(&second_capture).context("composer row after reflow wait")?; anyhow::ensure!( first_row == second_row, "composer row drifted after split: before={first_row}, after={second_row}\n\ before:\n{first_capture}\n\ after:\n{second_capture}" ); anyhow::ensure!( second_row <= baseline_row + 1, "composer row snapped downward after split: baseline={baseline_row}, after={second_row}\n\ baseline:\n{baseline_capture}\n\ after:\n{second_capture}" ); check( Command::new("tmux") .arg("kill-pane") .arg("-t") .arg(&split_pane), )?; sleep(Duration::from_millis(/*millis*/ 500)); let final_capture = capture_pane(&codex_pane)?; let final_row = last_composer_row(&final_capture).context("composer row after closing split")?; anyhow::ensure!( final_row == baseline_row, "composer row drifted after closing split: baseline={baseline_row}, after={final_row}\n\ capture:\n{final_capture}" ); let final_history_row = first_row_containing(&final_capture, "resize reflow sentinel") .context("history row after closing split")?; anyhow::ensure!( final_history_row == baseline_history_row, "history row drifted after closing split: baseline={baseline_history_row}, \ after={final_history_row}\n\ baseline:\n{baseline_capture}\n\ after:\n{final_capture}" ); Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]async fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> { if cfg!(windows) { return Ok(()); } skip_if_no_network!(Ok(())); if Command::new("tmux").arg("-V").output().is_err() { eprintln!("skipping resize smoke because tmux is unavailable"); return Ok(()); } run_repeated_resize_smoke().await?; Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]async fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { if cfg!(windows) { return Ok(()); } skip_if_no_network!(Ok(())); if Command::new("tmux").arg("-V").output().is_err() { eprintln!("skipping resize smoke because tmux is unavailable"); return Ok(()); } let repo_root = codex_utils_cargo_bin::repo_root()?; let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); write_config(codex_home.path(), &repo_root)?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-width-{}", std::process::id()); let _session = TmuxSession { name: session_name.clone(), }; let prompt = "Send me a large paragraph of text for testing."; let start_output = checked_output( Command::new("tmux") .arg("new-session") .arg("-d") .arg("-P") .arg("-F") .arg("#{pane_id}") .arg("-x") .arg("120") .arg("-y") .arg("40") .arg("-s") .arg(&session_name) .arg("--") .arg("env") .arg(format!("CODEX_HOME={}", codex_home.path().display())) .arg("OPENAI_API_KEY=dummy") .arg(codex) .arg("-c") .arg("analytics.enabled=false") .arg("-c") .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) .arg(prompt), )?; let codex_pane = stdout_text(&start_output).trim().to_string(); anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); wait_for_capture_contains( &codex_pane, "resize reflow sentinel", Duration::from_secs(/*secs*/ 15), )?; wait_for_capture_contains( &codex_pane, "gpt-5.4 default", Duration::from_secs(/*secs*/ 15), )?; let draft = "Notice where we are here in terms of y location."; check( Command::new("tmux") .arg("send-keys") .arg("-t") .arg(&codex_pane) .arg("-l") .arg(draft), )?; let baseline_capture = wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") .context("history row before split")?; let split_output = checked_output( Command::new("tmux") .arg("split-window") .arg("-d") .arg("-P") .arg("-F") .arg("#{pane_id}") .arg("-h") .arg("-l") .arg("40") .arg("-t") .arg(&codex_pane) .arg("sleep") .arg("30"), )?; let split_pane = stdout_text(&split_output).trim().to_string(); sleep(Duration::from_millis(/*millis*/ 750)); check( Command::new("tmux") .arg("kill-pane") .arg("-t") .arg(&split_pane), )?; sleep(Duration::from_millis(/*millis*/ 1_000)); let restored_capture = capture_pane(&codex_pane)?; let restored_row = last_composer_row(&restored_capture).context("composer row after width restore")?; let restored_history_row = first_row_containing(&restored_capture, "resize reflow sentinel") .context("history row after width restore")?; anyhow::ensure!( restored_row == baseline_row, "composer row drifted after width restore: baseline={baseline_row}, \ restored={restored_row}\n\ baseline:\n{baseline_capture}\n\ restored:\n{restored_capture}" ); anyhow::ensure!( restored_history_row == baseline_history_row, "history row drifted after width restore: baseline={baseline_history_row}, \ restored={restored_history_row}\n\ baseline:\n{baseline_capture}\n\ restored:\n{restored_capture}" ); Ok(())}async fn run_repeated_resize_smoke() -> Result<()> { let repo_root = codex_utils_cargo_bin::repo_root()?; let codex = codex_binary(&repo_root)?; let codex_home = tempdir()?; let server = MockServer::start().await; let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); write_config(codex_home.path(), &repo_root)?; write_auth(codex_home.path())?; let session_name = format!("codex-resize-repeat-{}", std::process::id()); let _session = TmuxSession { name: session_name.clone(), }; let prompt = "Send me a large paragraph of text for testing."; let start_output = checked_output( Command::new("tmux") .arg("new-session") .arg("-d") .arg("-P") .arg("-F") .arg("#{pane_id}") .arg("-x") .arg("120") .arg("-y") .arg("40") .arg("-s") .arg(&session_name) .arg("--") .arg("env") .arg(format!("CODEX_HOME={}", codex_home.path().display())) .arg("OPENAI_API_KEY=dummy") .arg(codex) .arg("-c") .arg("analytics.enabled=false") .arg("-c") .arg(&openai_base_url_config) .arg("--no-alt-screen") .arg("-C") .arg(&repo_root) .arg(prompt), )?; let codex_pane = stdout_text(&start_output).trim().to_string(); anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); wait_for_capture_contains( &codex_pane, "resize reflow sentinel", Duration::from_secs(/*secs*/ 15), )?; wait_for_capture_contains( &codex_pane, "gpt-5.4 default", Duration::from_secs(/*secs*/ 15), )?; let draft = "Notice where we are here in terms of y location."; check( Command::new("tmux") .arg("send-keys") .arg("-t") .arg(&codex_pane) .arg("-l") .arg(draft), )?; let baseline_capture = wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") .context("history row before split")?; for cycle in 1..=3 { let split_output = checked_output( Command::new("tmux") .arg("split-window") .arg("-d") .arg("-P") .arg("-F") .arg("#{pane_id}") .arg("-v") .arg("-l") .arg("12") .arg("-t") .arg(&codex_pane) .arg("sleep") .arg("30"), )?; let split_pane = stdout_text(&split_output).trim().to_string(); sleep(Duration::from_millis(/*millis*/ 250)); check( Command::new("tmux") .arg("kill-pane") .arg("-t") .arg(&split_pane), )?; sleep(Duration::from_millis(/*millis*/ 500)); let restored_capture = capture_pane(&codex_pane)?; let restored_row = last_composer_row(&restored_capture) .with_context(|| format!("composer row after resize cycle {cycle}"))?; let restored_history_row = first_row_containing(&restored_capture, "resize reflow sentinel") .with_context(|| format!("history row after resize cycle {cycle}"))?; anyhow::ensure!( restored_row == baseline_row, "composer row drifted after resize cycle {cycle}: baseline={baseline_row}, \ restored={restored_row}\n\ baseline:\n{baseline_capture}\n\ restored:\n{restored_capture}" ); anyhow::ensure!( restored_history_row == baseline_history_row, "history row drifted after resize cycle {cycle}: baseline={baseline_history_row}, \ restored={restored_history_row}\n\ baseline:\n{baseline_capture}\n\ restored:\n{restored_capture}" ); } Ok(())}struct TmuxSession { name: String,}impl Drop for TmuxSession { fn drop(&mut self) { let _ = Command::new("tmux") .arg("kill-session") .arg("-t") .arg(&self.name) .output(); }}fn codex_binary(repo_root: &Path) -> Result<PathBuf> { if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { return Ok(path); } let fallback = repo_root.join("codex-rs/target/debug/codex"); anyhow::ensure!( fallback.is_file(), "codex binary is unavailable; run `cargo build -p codex-cli` first" ); Ok(fallback)}fn write_config(codex_home: &Path, repo_root: &Path) -> Result<()> { let repo_root_display = repo_root.display(); let config = format!( r#"model = "gpt-5.4"model_provider = "openai"suppress_unstable_features_warning = true[projects."{repo_root_display}"]trust_level = "trusted""# ); std::fs::write(codex_home.join("config.toml"), config)?; Ok(())}fn write_auth(codex_home: &Path) -> Result<()> { std::fs::write( codex_home.join("auth.json"), r#"{"OPENAI_API_KEY":"dummy","tokens":null,"last_refresh":null}"#, )?; Ok(())}fn resize_reflow_sse() -> String { let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height."; responses::sse(vec![ responses::ev_response_created("resp-resize-smoke"), responses::ev_assistant_message("msg-resize-smoke", text), responses::ev_completed("resp-resize-smoke"), ])}fn wait_for_capture_contains(pane: &str, needle: &str, timeout: Duration) -> Result<String> { let deadline = Instant::now() + timeout; let mut last_capture = String::new(); while Instant::now() < deadline { last_capture = capture_pane(pane)?; if last_capture.contains(needle) { return Ok(last_capture); } sleep(Duration::from_millis(/*millis*/ 100)); } anyhow::bail!("timed out waiting for {needle:?}; last capture:\n{last_capture}");}fn capture_pane(pane: &str) -> Result<String> { let output = output( Command::new("tmux") .arg("capture-pane") .arg("-p") .arg("-t") .arg(pane), )?; Ok(String::from_utf8_lossy(&output.stdout).to_string())}fn last_composer_row(capture: &str) -> Option<usize> { capture .lines() .enumerate() .filter_map(|(index, line)| { if line.trim_start().starts_with('\u{203a}') { Some(index) } else { None } }) .last()}fn first_row_containing(capture: &str, needle: &str) -> Option<usize> { capture .lines() .enumerate() .find_map(|(index, line)| line.contains(needle).then_some(index))}fn check(command: &mut Command) -> Result<()> { checked_output(command)?; Ok(())}fn checked_output(command: &mut Command) -> Result<Output> { let output = output(command)?; anyhow::ensure!( output.status.success(), "command failed with status {:?}\nstdout:\n{}\nstderr:\n{}", output.status.code(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); Ok(output)}fn output(command: &mut Command) -> Result<Output> { command .output() .with_context(|| format!("failed to run {command:?}"))}fn stdout_text(output: &Output) -> String { String::from_utf8_lossy(&output.stdout).to_string()}