| 1 | //! Automated terminal / key / modal acceptance matrix for #3758. |
| 2 | //! |
| 3 | //! #3758 asked for a multi-terminal QA pass. Most of that report is |
| 4 | //! automatable: terminal geometry, `TERM`/`COLORTERM` capability tiers, the |
| 5 | //! Unicode→ASCII fallback, every paste shape a real terminal can deliver, |
| 6 | //! IME-style commits, mouse/resize/focus events, and every modal open/close |
| 7 | //! path. Those cells run here, provider-free, in real pseudo-terminals. |
| 8 | //! |
| 9 | //! What deliberately does **not** live here: literal physical observations in |
| 10 | //! iTerm2 / Terminal.app / WezTerm / a real SSH session. A PTY reproduces the |
| 11 | //! byte protocol, not the emulator, so those rows stay `UNRUN` in |
| 12 | //! `docs/releases/v0.9.2-terminal-matrix.md` rather than being claimed by |
| 13 | //! proxy. |
| 14 | //! |
| 15 | //! Conventions this file holds to, because the alternative is a matrix that |
| 16 | //! looks green and proves nothing: |
| 17 | //! |
| 18 | //! - **No sleep as correctness.** Every wait is a bounded poll on a real |
| 19 | //! signal (rendered text, view-stack trace record, process exit) and fails |
| 20 | //! with the frame *and* the terminal-mode ledger. The only fixed sleeps are |
| 21 | //! the paste-burst settle windows, which are part of the behaviour under |
| 22 | //! test, not a substitute for synchronisation. |
| 23 | //! - **No duplicate shortcut table.** Chord coverage is scraped from the help |
| 24 | //! overlay the product actually renders from |
| 25 | //! `crate::tui::keybindings::KEYBINDINGS`; the catalog's own invariants are |
| 26 | //! pinned by unit tests next to it. |
| 27 | //! - **Terminal modes are checked on every exit path**, from the raw control |
| 28 | //! stream rather than the screen. |
| 29 | |
| 30 | #![cfg(unix)] |
| 31 | |
| 32 | #[path = "support/qa_harness/mod.rs"] |
| 33 | mod qa_harness; |
| 34 | |
| 35 | use std::sync::{Mutex, MutexGuard}; |
| 36 | use std::time::{Duration, Instant}; |
| 37 | |
| 38 | use anyhow::{Result, anyhow}; |
| 39 | use qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace}; |
| 40 | use qa_harness::modes::{MODES_THAT_MUST_NOT_LEAK, mode}; |
| 41 | use qa_harness::view_log::{self, VIEW_STACK_RUST_LOG}; |
| 42 | use qa_harness::{Frame, keys}; |
| 43 | |
| 44 | const BOOT_TIMEOUT: Duration = Duration::from_secs(20); |
| 45 | const KEY_TIMEOUT: Duration = Duration::from_secs(6); |
| 46 | const MODAL_TIMEOUT: Duration = Duration::from_secs(8); |
| 47 | const EXIT_TIMEOUT: Duration = Duration::from_secs(10); |
| 48 | /// The paste-burst detector suppresses a trailing Enter for ~120 ms after a |
| 49 | /// burst. Waiting past it is part of the contract under test, not a stand-in |
| 50 | /// for synchronisation. |
| 51 | const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(180); |
| 52 | const COMPOSER_READY_TEXT: &str = "Write a task"; |
| 53 | |
| 54 | /// PTY scenarios each boot a real binary and contend for CPU; run them one at |
| 55 | /// a time so a wait budget measures the product, not the runner. |
| 56 | static TERMINAL_MATRIX_LOCK: Mutex<()> = Mutex::new(()); |
| 57 | |
| 58 | fn matrix_lock() -> MutexGuard<'static, ()> { |
| 59 | TERMINAL_MATRIX_LOCK |
| 60 | .lock() |
| 61 | .unwrap_or_else(|poison| poison.into_inner()) |
| 62 | } |
| 63 | |
| 64 | /// One column of the capability matrix: what the terminal claims to be. |
| 65 | #[derive(Debug, Clone, Copy)] |
| 66 | struct TerminalProfile { |
| 67 | name: &'static str, |
| 68 | term: &'static str, |
| 69 | /// Empty means "the variable is present but says nothing", which is how a |
| 70 | /// terminal that does not advertise truecolor actually behaves. The |
| 71 | /// harness always sets `COLORTERM`, so this is the honest way to model |
| 72 | /// its absence. |
| 73 | colorterm: &'static str, |
| 74 | extra_env: &'static [(&'static str, &'static str)], |
| 75 | /// Whether 24-bit SGR is permitted to reach the terminal on this profile. |
| 76 | truecolor_allowed: bool, |
| 77 | } |
| 78 | |
| 79 | const CAPABILITY_PROFILES: &[TerminalProfile] = &[ |
| 80 | TerminalProfile { |
| 81 | name: "xterm-256color + COLORTERM=truecolor", |
| 82 | term: "xterm-256color", |
| 83 | colorterm: "truecolor", |
| 84 | extra_env: &[], |
| 85 | truecolor_allowed: true, |
| 86 | }, |
| 87 | TerminalProfile { |
| 88 | name: "xterm-256color, no truecolor claim", |
| 89 | term: "xterm-256color", |
| 90 | colorterm: "", |
| 91 | extra_env: &[], |
| 92 | truecolor_allowed: false, |
| 93 | }, |
| 94 | TerminalProfile { |
| 95 | name: "xterm (unknown tier)", |
| 96 | term: "xterm", |
| 97 | colorterm: "", |
| 98 | extra_env: &[], |
| 99 | truecolor_allowed: false, |
| 100 | }, |
| 101 | TerminalProfile { |
| 102 | name: "screen-256color (tmux-style)", |
| 103 | term: "screen-256color", |
| 104 | colorterm: "", |
| 105 | extra_env: &[], |
| 106 | truecolor_allowed: false, |
| 107 | }, |
| 108 | TerminalProfile { |
| 109 | name: "NO_COLOR present", |
| 110 | term: "xterm-256color", |
| 111 | colorterm: "truecolor", |
| 112 | extra_env: &[("NO_COLOR", "1")], |
| 113 | // NO_COLOR is not honored by the palette today. This row proves the |
| 114 | // TUI still boots and paints with it set; it deliberately does not |
| 115 | // claim color suppression, which is not a v0.9.2 contract. |
| 116 | truecolor_allowed: true, |
| 117 | }, |
| 118 | TerminalProfile { |
| 119 | name: "CODEWHALE_ASCII_SAFE=1", |
| 120 | term: "xterm-256color", |
| 121 | colorterm: "truecolor", |
| 122 | extra_env: &[("CODEWHALE_ASCII_SAFE", "1")], |
| 123 | truecolor_allowed: true, |
| 124 | }, |
| 125 | ]; |
| 126 | |
| 127 | /// Terminal geometries the release supports, from the smallest pane a user |
| 128 | /// realistically splits down to a full-screen 4K terminal. |
| 129 | const SIZE_MATRIX: &[(u16, u16, &str)] = &[ |
| 130 | (24, 80, "classic 80x24"), |
| 131 | (40, 120, "laptop 120x40"), |
| 132 | (20, 60, "narrow split 60x20"), |
| 133 | (14, 48, "tiny pane 48x14"), |
| 134 | (50, 200, "wide 200x50"), |
| 135 | ]; |
| 136 | |
| 137 | /// Decorative glyphs the ASCII-safe tier promises to narrow. Each one is in |
| 138 | /// `crate::tui::glyphs::ascii_fallback`'s explicit table, so this asserts a |
| 139 | /// published mapping rather than a guessed Unicode range. |
| 140 | const DECORATIVE_GLYPHS: &[char] = &[ |
| 141 | '─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '╭', '╮', '╰', '╯', '█', '▌', '▐', '▶', |
| 142 | '◀', '●', '○', '■', '◆', '…', |
| 143 | ]; |
| 144 | |
| 145 | fn spawn( |
| 146 | ws: &SealedWorkspace, |
| 147 | profile: TerminalProfile, |
| 148 | rows: u16, |
| 149 | cols: u16, |
| 150 | rust_log: &str, |
| 151 | ) -> Result<Harness> { |
| 152 | let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 153 | .cwd(ws.workspace()) |
| 154 | .clear_env() |
| 155 | .seal_home(ws.home()) |
| 156 | // A stub key skips onboarding; a refused loopback base URL guarantees |
| 157 | // no request can escape the box. Nothing in this file needs a |
| 158 | // provider — the rows that need a running turn live in |
| 159 | // `release_runtime_qa.rs` against a loopback mock. |
| 160 | .env("DEEPSEEK_API_KEY", "ci-test-key-not-real") |
| 161 | .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1") |
| 162 | .env("NO_ANIMATIONS", "1") |
| 163 | .env("RUST_LOG", rust_log) |
| 164 | .env("TERM", profile.term) |
| 165 | .env("COLORTERM", profile.colorterm) |
| 166 | .args([ |
| 167 | "--workspace", |
| 168 | ws.workspace().to_str().expect("utf-8 workspace path"), |
| 169 | "--no-project-config", |
| 170 | "--skip-onboarding", |
| 171 | ]) |
| 172 | .size(rows, cols); |
| 173 | for (key, value) in profile.extra_env { |
| 174 | builder = builder.env(*key, *value); |
| 175 | } |
| 176 | builder.spawn() |
| 177 | } |
| 178 | |
| 179 | fn default_profile() -> TerminalProfile { |
| 180 | CAPABILITY_PROFILES[0] |
| 181 | } |
| 182 | |
| 183 | fn boot(ws: &SealedWorkspace, rows: u16, cols: u16) -> Result<Harness> { |
| 184 | let mut harness = spawn(ws, default_profile(), rows, cols, "warn")?; |
| 185 | expect_text(&mut harness, COMPOSER_READY_TEXT, BOOT_TIMEOUT, "boot")?; |
| 186 | Ok(harness) |
| 187 | } |
| 188 | |
| 189 | /// Bounded wait for rendered text that fails with the frame *and* the |
| 190 | /// terminal-mode ledger, so a CI timeout is diagnosable without a rerun. |
| 191 | fn expect_text( |
| 192 | harness: &mut Harness, |
| 193 | needle: &str, |
| 194 | timeout: Duration, |
| 195 | context: &str, |
| 196 | ) -> Result<()> { |
| 197 | match harness.wait_for_text(needle, timeout) { |
| 198 | Ok(()) => Ok(()), |
| 199 | Err(err) => { |
| 200 | let modes = harness.terminal_modes().debug_dump(); |
| 201 | Err(anyhow!("{context}: {err:#}\n{modes}")) |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | fn expect_frame<F>( |
| 207 | harness: &mut Harness, |
| 208 | predicate: F, |
| 209 | timeout: Duration, |
| 210 | context: &str, |
| 211 | ) -> Result<()> |
| 212 | where |
| 213 | F: FnMut(&Frame) -> bool, |
| 214 | { |
| 215 | match harness.wait_for(predicate, timeout) { |
| 216 | Ok(()) => Ok(()), |
| 217 | Err(err) => { |
| 218 | let modes = harness.terminal_modes().debug_dump(); |
| 219 | Err(anyhow!("{context}: {err:#}\n{modes}")) |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Type a slash command and run it, waiting on the echo rather than sleeping. |
| 225 | fn run_command(harness: &mut Harness, command: &str) -> Result<()> { |
| 226 | harness.send(keys::key::text(command))?; |
| 227 | expect_text(harness, command, KEY_TIMEOUT, &format!("echo of {command}"))?; |
| 228 | std::thread::sleep(PASTE_GUARD_SETTLE); |
| 229 | harness.pump(); |
| 230 | harness.send(keys::key::enter())?; |
| 231 | Ok(()) |
| 232 | } |
| 233 | |
| 234 | fn assert_no_leaked_modes(harness: &Harness, context: &str) { |
| 235 | let ledger = harness.terminal_modes(); |
| 236 | let leaked = ledger.leaked_modes(); |
| 237 | assert!( |
| 238 | leaked.is_empty(), |
| 239 | "{context}: terminal modes left enabled after exit: {leaked:?}\n{}", |
| 240 | ledger.debug_dump() |
| 241 | ); |
| 242 | assert!( |
| 243 | ledger.keyboard_pops() >= ledger.keyboard_pushes(), |
| 244 | "{context}: keyboard enhancement stack not unwound ({} pushed, {} popped) — this is the \ |
| 245 | `^[[>5u` shell pollution from #1583\n{}", |
| 246 | ledger.keyboard_pushes(), |
| 247 | ledger.keyboard_pops(), |
| 248 | ledger.debug_dump() |
| 249 | ); |
| 250 | if ledger.was_ever_enabled(mode::ALT_SCREEN) { |
| 251 | assert_eq!( |
| 252 | ledger.state(mode::CURSOR_VISIBLE), |
| 253 | Some(true), |
| 254 | "{context}: cursor left hidden on the restored primary screen\n{}", |
| 255 | ledger.debug_dump() |
| 256 | ); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // --------------------------------------------------------------------------- |
| 261 | // Geometry |
| 262 | // --------------------------------------------------------------------------- |
| 263 | |
| 264 | /// Every supported terminal size keeps the composer reachable and paints |
| 265 | /// inside the viewport. Driven as live resizes on one process because that is |
| 266 | /// also the resize contract: a user dragging a pane must never be left with a |
| 267 | /// blank or overflowing frame. |
| 268 | #[test] |
| 269 | fn size_matrix_keeps_the_composer_visible_and_inside_the_viewport() -> Result<()> { |
| 270 | let _guard = matrix_lock(); |
| 271 | let ws = make_sealed_workspace()?; |
| 272 | let mut harness = boot(&ws, 40, 120)?; |
| 273 | |
| 274 | for (rows, cols, label) in SIZE_MATRIX { |
| 275 | let transcript_before_resize = harness.transcript().len(); |
| 276 | harness.resize(*rows, *cols)?; |
| 277 | let deadline = Instant::now() + qa_harness::harness::ci_scaled(KEY_TIMEOUT); |
| 278 | loop { |
| 279 | harness.pump(); |
| 280 | let saw_post_resize_output = harness.transcript().len() > transcript_before_resize; |
| 281 | let frame = harness.frame(); |
| 282 | if saw_post_resize_output |
| 283 | && frame.rows() == *rows |
| 284 | && frame.cols() == *cols |
| 285 | && frame.any_visible_text() |
| 286 | && frame.contains(COMPOSER_READY_TEXT) |
| 287 | { |
| 288 | break; |
| 289 | } |
| 290 | if Instant::now() >= deadline { |
| 291 | return Err(anyhow!( |
| 292 | "painted composer after resize to {label}: timed out\n{}", |
| 293 | harness.diagnostics() |
| 294 | )); |
| 295 | } |
| 296 | std::thread::sleep(Duration::from_millis(40)); |
| 297 | } |
| 298 | |
| 299 | let frame = harness.frame(); |
| 300 | let dump = frame.debug_dump(); |
| 301 | assert!( |
| 302 | frame.any_visible_text(), |
| 303 | "{label}: viewport went blank after resize:\n{dump}" |
| 304 | ); |
| 305 | assert!( |
| 306 | frame.max_row_width() <= usize::from(*cols), |
| 307 | "{label}: a row overflowed {cols} columns:\n{dump}" |
| 308 | ); |
| 309 | let (cursor_row, cursor_col) = frame.cursor(); |
| 310 | assert!( |
| 311 | cursor_row < *rows && cursor_col < *cols, |
| 312 | "{label}: cursor left the viewport at {cursor_row}x{cursor_col}:\n{dump}" |
| 313 | ); |
| 314 | } |
| 315 | |
| 316 | // Typing must still land in the composer at the last (widest) size, so a |
| 317 | // resize storm cannot silently detach input. |
| 318 | harness.send(keys::key::text("post-resize input"))?; |
| 319 | expect_text( |
| 320 | &mut harness, |
| 321 | "post-resize input", |
| 322 | KEY_TIMEOUT, |
| 323 | "composer input after the full resize sweep", |
| 324 | )?; |
| 325 | |
| 326 | let _ = harness.shutdown(); |
| 327 | Ok(()) |
| 328 | } |
| 329 | |
| 330 | // --------------------------------------------------------------------------- |
| 331 | // Capability tiers |
| 332 | // --------------------------------------------------------------------------- |
| 333 | |
| 334 | /// Boot under each `TERM`/`COLORTERM` tier and prove the palette honored it. |
| 335 | /// The assertion reads parsed ANSI out of the PTY, not the renderer's |
| 336 | /// intent — a terminal that only advertises 256 colors must never receive |
| 337 | /// `38;2` truecolor SGR (#2494 item 3). |
| 338 | #[test] |
| 339 | fn capability_matrix_honors_the_advertised_color_tier() -> Result<()> { |
| 340 | let _guard = matrix_lock(); |
| 341 | |
| 342 | for profile in CAPABILITY_PROFILES { |
| 343 | let ws = make_sealed_workspace()?; |
| 344 | let mut harness = spawn(&ws, *profile, 40, 120, "warn")?; |
| 345 | expect_text( |
| 346 | &mut harness, |
| 347 | COMPOSER_READY_TEXT, |
| 348 | BOOT_TIMEOUT, |
| 349 | &format!("boot under {}", profile.name), |
| 350 | )?; |
| 351 | |
| 352 | let frame = harness.frame(); |
| 353 | let dump = frame.debug_dump(); |
| 354 | assert!( |
| 355 | frame.any_visible_text(), |
| 356 | "{}: booted to a blank screen:\n{dump}", |
| 357 | profile.name |
| 358 | ); |
| 359 | if !profile.truecolor_allowed { |
| 360 | assert!( |
| 361 | !frame.any_truecolor_cell(), |
| 362 | "{}: 24-bit SGR reached a terminal that never advertised it:\n{dump}", |
| 363 | profile.name |
| 364 | ); |
| 365 | } |
| 366 | |
| 367 | let _ = harness.shutdown(); |
| 368 | } |
| 369 | |
| 370 | Ok(()) |
| 371 | } |
| 372 | |
| 373 | /// `CODEWHALE_ASCII_SAFE=1` must narrow every CodeWhale-authored decorative |
| 374 | /// glyph, and the default tier must actually differ — otherwise the fallback |
| 375 | /// is untested and the "ASCII terminals are supported" claim is unbacked. |
| 376 | #[test] |
| 377 | fn ascii_safe_tier_removes_decorative_glyphs_the_default_tier_paints() -> Result<()> { |
| 378 | let _guard = matrix_lock(); |
| 379 | |
| 380 | let rich_ws = make_sealed_workspace()?; |
| 381 | let mut rich = spawn(&rich_ws, default_profile(), 40, 120, "warn")?; |
| 382 | expect_text(&mut rich, COMPOSER_READY_TEXT, BOOT_TIMEOUT, "rich boot")?; |
| 383 | let rich_painted = rich.frame().painted_chars(); |
| 384 | let rich_dump = rich.debug_dump(); |
| 385 | let _ = rich.shutdown(); |
| 386 | |
| 387 | let rich_decorative: Vec<char> = DECORATIVE_GLYPHS |
| 388 | .iter() |
| 389 | .copied() |
| 390 | .filter(|glyph| rich_painted.contains(glyph)) |
| 391 | .collect(); |
| 392 | assert!( |
| 393 | !rich_decorative.is_empty(), |
| 394 | "default tier painted no decorative glyph, so the ASCII fallback below \ |
| 395 | would pass vacuously:\n{rich_dump}" |
| 396 | ); |
| 397 | |
| 398 | let ascii_profile = CAPABILITY_PROFILES |
| 399 | .iter() |
| 400 | .find(|profile| profile.name == "CODEWHALE_ASCII_SAFE=1") |
| 401 | .copied() |
| 402 | .expect("ascii-safe profile is declared in the capability matrix"); |
| 403 | let ascii_ws = make_sealed_workspace()?; |
| 404 | let mut ascii = spawn(&ascii_ws, ascii_profile, 40, 120, "warn")?; |
| 405 | expect_text( |
| 406 | &mut ascii, |
| 407 | COMPOSER_READY_TEXT, |
| 408 | BOOT_TIMEOUT, |
| 409 | "ascii-safe boot", |
| 410 | )?; |
| 411 | let ascii_painted = ascii.frame().painted_chars(); |
| 412 | let ascii_dump = ascii.debug_dump(); |
| 413 | let _ = ascii.shutdown(); |
| 414 | |
| 415 | let leaked: Vec<char> = DECORATIVE_GLYPHS |
| 416 | .iter() |
| 417 | .copied() |
| 418 | .filter(|glyph| ascii_painted.contains(glyph)) |
| 419 | .collect(); |
| 420 | assert!( |
| 421 | leaked.is_empty(), |
| 422 | "ASCII-safe tier still painted {leaked:?}:\n{ascii_dump}" |
| 423 | ); |
| 424 | let braille: Vec<char> = ascii_painted |
| 425 | .iter() |
| 426 | .copied() |
| 427 | .filter(|ch| ('\u{2800}'..='\u{28FF}').contains(ch)) |
| 428 | .collect(); |
| 429 | assert!( |
| 430 | braille.is_empty(), |
| 431 | "ASCII-safe tier still painted braille state markers {braille:?}:\n{ascii_dump}" |
| 432 | ); |
| 433 | |
| 434 | Ok(()) |
| 435 | } |
| 436 | |
| 437 | // --------------------------------------------------------------------------- |
| 438 | // Paste / IME |
| 439 | // --------------------------------------------------------------------------- |
| 440 | |
| 441 | /// Whether the frame shows a turn that actually left the composer. Boot is |
| 442 | /// provider-free against a refused loopback port, so any dispatch surfaces as |
| 443 | /// a connection failure rather than a reply. |
| 444 | fn frame_shows_dispatched_turn(frame: &Frame) -> bool { |
| 445 | frame.contains("Turn failed") || frame.contains("Connection refused") || frame.contains("error") |
| 446 | } |
| 447 | |
| 448 | /// Every paste shape a real terminal can deliver — bracketed, raw, multiline, |
| 449 | /// CJK, and a very large payload — must land in the composer and must not |
| 450 | /// auto-submit. #1073 and the v0.9.2 real-PTY paste trace are both regressions |
| 451 | /// of exactly this cell. |
| 452 | #[test] |
| 453 | fn paste_matrix_lands_in_the_composer_without_autosubmitting() -> Result<()> { |
| 454 | let _guard = matrix_lock(); |
| 455 | |
| 456 | // Each case is (label, payload, bracketed). The marker each payload ends |
| 457 | // with is what the assertion looks for, so a silently truncated paste |
| 458 | // fails instead of passing on its prefix. |
| 459 | let cases: &[(&str, String, bool)] = &[ |
| 460 | ( |
| 461 | "bracketed single line", |
| 462 | "matrix-bracketed-single-END".to_string(), |
| 463 | true, |
| 464 | ), |
| 465 | ( |
| 466 | "bracketed multiline", |
| 467 | "matrix-line-one\nmatrix-line-two\nmatrix-bracketed-multi-END".to_string(), |
| 468 | true, |
| 469 | ), |
| 470 | ( |
| 471 | "bracketed with trailing newline", |
| 472 | "matrix-bracketed-trailing-END\n".to_string(), |
| 473 | true, |
| 474 | ), |
| 475 | ( |
| 476 | "bracketed CJK + wide glyphs", |
| 477 | "你好世界 マトリクス 매트릭스 matrix-cjk-END".to_string(), |
| 478 | true, |
| 479 | ), |
| 480 | ( |
| 481 | "raw unbracketed multiline", |
| 482 | "matrix-raw-one\nmatrix-raw-two\nmatrix-raw-END".to_string(), |
| 483 | false, |
| 484 | ), |
| 485 | ( |
| 486 | "large bracketed payload", |
| 487 | format!("{}matrix-large-END", "abcdefghij ".repeat(180)), |
| 488 | true, |
| 489 | ), |
| 490 | ]; |
| 491 | |
| 492 | for (label, payload, bracketed) in cases { |
| 493 | let ws = make_sealed_workspace()?; |
| 494 | let mut harness = boot(&ws, 40, 120)?; |
| 495 | |
| 496 | if *bracketed { |
| 497 | harness.paste(payload)?; |
| 498 | } else { |
| 499 | harness.paste_unbracketed(payload)?; |
| 500 | } |
| 501 | |
| 502 | let marker = payload |
| 503 | .trim_end() |
| 504 | .rsplit(['\n', ' ']) |
| 505 | .next() |
| 506 | .expect("every payload ends with a marker token") |
| 507 | .to_string(); |
| 508 | expect_text( |
| 509 | &mut harness, |
| 510 | &marker, |
| 511 | KEY_TIMEOUT, |
| 512 | &format!("{label}: pasted tail never reached the composer"), |
| 513 | )?; |
| 514 | |
| 515 | // Give any trailing-newline-driven submit the chance to happen before |
| 516 | // asserting that it did not. This window is the paste-burst guard |
| 517 | // itself, not a synchronisation crutch. |
| 518 | std::thread::sleep(PASTE_GUARD_SETTLE); |
| 519 | harness.pump(); |
| 520 | let frame = harness.frame(); |
| 521 | let dump = frame.debug_dump(); |
| 522 | assert!( |
| 523 | !frame_shows_dispatched_turn(frame), |
| 524 | "{label}: paste auto-submitted; nothing may dispatch without an explicit Enter:\n{dump}" |
| 525 | ); |
| 526 | |
| 527 | let _ = harness.shutdown(); |
| 528 | } |
| 529 | |
| 530 | Ok(()) |
| 531 | } |
| 532 | |
| 533 | /// An IME delivers each committed character as its own event with human-scale |
| 534 | /// gaps. That is typing, not a paste: the Enter that follows a committed CJK |
| 535 | /// sentence must submit rather than being absorbed as a paste's trailing |
| 536 | /// newline. Covers the lone-commit short-window path from the v0.9.2 paste |
| 537 | /// trace as well as a multi-character commit. |
| 538 | #[test] |
| 539 | fn ime_style_commits_are_typing_and_the_following_enter_submits() -> Result<()> { |
| 540 | let _guard = matrix_lock(); |
| 541 | let ws = make_sealed_workspace()?; |
| 542 | let mut harness = boot(&ws, 40, 120)?; |
| 543 | |
| 544 | // Multi-character commit, one candidate at a time. |
| 545 | for ch in "行列テスト".chars() { |
| 546 | harness.send(keys::key::ch(ch))?; |
| 547 | std::thread::sleep(Duration::from_millis(60)); |
| 548 | } |
| 549 | expect_text( |
| 550 | &mut harness, |
| 551 | "行列テスト", |
| 552 | KEY_TIMEOUT, |
| 553 | "IME commits never echoed", |
| 554 | )?; |
| 555 | let frame = harness.frame(); |
| 556 | let dump = frame.debug_dump(); |
| 557 | assert!( |
| 558 | !frame_shows_dispatched_turn(frame), |
| 559 | "IME composition must not dispatch on its own:\n{dump}" |
| 560 | ); |
| 561 | |
| 562 | // A lone trailing commit followed by Enter is the exact shape that used to |
| 563 | // re-arm the paste-burst window and swallow the send. |
| 564 | harness.send(keys::key::ch('了'))?; |
| 565 | std::thread::sleep(Duration::from_millis(50)); |
| 566 | harness.send(keys::key::enter())?; |
| 567 | |
| 568 | expect_frame( |
| 569 | &mut harness, |
| 570 | frame_shows_dispatched_turn, |
| 571 | Duration::from_secs(15), |
| 572 | "IME-typed message never submitted on Enter", |
| 573 | )?; |
| 574 | |
| 575 | let _ = harness.shutdown(); |
| 576 | Ok(()) |
| 577 | } |
| 578 | |
| 579 | // --------------------------------------------------------------------------- |
| 580 | // Mouse / resize / focus |
| 581 | // --------------------------------------------------------------------------- |
| 582 | |
| 583 | /// Mouse, resize and focus events must never be decoded as text, and |
| 584 | /// `FocusGained` must re-establish the terminal modes the emulator may have |
| 585 | /// dropped while the window was in the background. |
| 586 | #[test] |
| 587 | fn mouse_resize_and_focus_events_never_reach_the_composer_as_text() -> Result<()> { |
| 588 | let _guard = matrix_lock(); |
| 589 | let ws = make_sealed_workspace()?; |
| 590 | let mut harness = boot(&ws, 40, 120)?; |
| 591 | |
| 592 | harness.send(keys::key::text("focus-sentinel"))?; |
| 593 | expect_text( |
| 594 | &mut harness, |
| 595 | "focus-sentinel", |
| 596 | KEY_TIMEOUT, |
| 597 | "sentinel draft", |
| 598 | )?; |
| 599 | |
| 600 | harness.send(keys::mouse::click(10, 20))?; |
| 601 | harness.send(keys::mouse::wheel_up(10, 20))?; |
| 602 | harness.send(keys::mouse::wheel_down(10, 20))?; |
| 603 | harness.send(keys::mouse::drag(12, 24))?; |
| 604 | harness.send(keys::focus::lost())?; |
| 605 | harness.resize(30, 100)?; |
| 606 | harness.send(keys::focus::gained())?; |
| 607 | harness.resize(40, 120)?; |
| 608 | |
| 609 | expect_text( |
| 610 | &mut harness, |
| 611 | "focus-sentinel", |
| 612 | KEY_TIMEOUT, |
| 613 | "draft after the mouse/focus/resize storm", |
| 614 | )?; |
| 615 | let frame = harness.frame(); |
| 616 | let dump = frame.debug_dump(); |
| 617 | for residue in ["[<0;", "[<64;", "[<65;", "[<32;", "\u{1b}[I", "\u{1b}[O"] { |
| 618 | assert!( |
| 619 | !frame.contains(residue), |
| 620 | "control sequence {residue:?} was painted as text:\n{dump}" |
| 621 | ); |
| 622 | } |
| 623 | assert!( |
| 624 | !frame_shows_dispatched_turn(frame), |
| 625 | "a mouse or focus event dispatched a turn:\n{dump}" |
| 626 | ); |
| 627 | |
| 628 | // FocusGained runs `recover_terminal_modes`, so focus reporting and |
| 629 | // bracketed paste must be *on* again while the process is still alive. |
| 630 | let ledger = harness.terminal_modes(); |
| 631 | assert_eq!( |
| 632 | ledger.state(mode::FOCUS), |
| 633 | Some(true), |
| 634 | "focus reporting was not re-established after FocusGained\n{}", |
| 635 | ledger.debug_dump() |
| 636 | ); |
| 637 | assert_eq!( |
| 638 | ledger.state(mode::BRACKETED_PASTE), |
| 639 | Some(true), |
| 640 | "bracketed paste was not re-established after FocusGained\n{}", |
| 641 | ledger.debug_dump() |
| 642 | ); |
| 643 | |
| 644 | let _ = harness.shutdown(); |
| 645 | Ok(()) |
| 646 | } |
| 647 | |
| 648 | // --------------------------------------------------------------------------- |
| 649 | // Modals |
| 650 | // --------------------------------------------------------------------------- |
| 651 | |
| 652 | /// How a modal is opened. Chords are the ones the help overlay advertises; |
| 653 | /// commands are the ones the keybinding catalog documents as the guaranteed |
| 654 | /// path for terminals that cannot encode the chord. |
| 655 | enum Opener { |
| 656 | Chord(&'static str, Vec<u8>), |
| 657 | Command(&'static str), |
| 658 | } |
| 659 | |
| 660 | impl Opener { |
| 661 | fn label(&self) -> &'static str { |
| 662 | match self { |
| 663 | Self::Chord(label, _) => label, |
| 664 | Self::Command(command) => command, |
| 665 | } |
| 666 | } |
| 667 | } |
| 668 | |
| 669 | /// Every modal that can be opened deterministically without a provider must |
| 670 | /// push exactly one view, and Esc must pop exactly that view and return the |
| 671 | /// stack to empty. Evidence is the product's own `view_stack` trace records, |
| 672 | /// not "the frame looked different" — a replaced modal and a closed modal |
| 673 | /// render the same way. |
| 674 | #[test] |
| 675 | fn every_provider_free_modal_opens_and_escape_returns_to_the_composer() -> Result<()> { |
| 676 | let _guard = matrix_lock(); |
| 677 | let ws = make_sealed_workspace()?; |
| 678 | let mut harness = spawn(&ws, default_profile(), 44, 140, VIEW_STACK_RUST_LOG)?; |
| 679 | expect_text(&mut harness, COMPOSER_READY_TEXT, BOOT_TIMEOUT, "boot")?; |
| 680 | |
| 681 | let openers = [ |
| 682 | (Opener::Chord("F1", keys::key::f1()), "Help"), |
| 683 | ( |
| 684 | Opener::Chord("Ctrl+K", keys::key::ctrl('k')), |
| 685 | "CommandPalette", |
| 686 | ), |
| 687 | (Opener::Command("/context"), "ContextInspector"), |
| 688 | (Opener::Command("/transcript"), "LiveTranscript"), |
| 689 | (Opener::Command("/theme"), "ThemePicker"), |
| 690 | (Opener::Command("/skills"), "SkillsManager"), |
| 691 | ]; |
| 692 | |
| 693 | let mut transition_cursor = view_log::read_events(ws.home()).unwrap_or_default().len(); |
| 694 | for (opener, kind) in &openers { |
| 695 | match opener { |
| 696 | Opener::Chord(_, bytes) => harness.send(bytes.clone())?, |
| 697 | Opener::Command(command) => run_command(&mut harness, command)?, |
| 698 | } |
| 699 | let (opened, last) = |
| 700 | view_log::wait_for_event_after(ws.home(), transition_cursor, MODAL_TIMEOUT, |event| { |
| 701 | event.is_open() && event.kind == *kind |
| 702 | }) |
| 703 | .map_err(|err| anyhow!("{} did not open {kind}: {err:#}", opener.label()))?; |
| 704 | transition_cursor = opened.len(); |
| 705 | assert_eq!( |
| 706 | last.depth, |
| 707 | 1, |
| 708 | "{} opened {kind} on top of a stack that was never unwound", |
| 709 | opener.label() |
| 710 | ); |
| 711 | |
| 712 | harness.send(keys::key::esc())?; |
| 713 | let (closed, last) = |
| 714 | view_log::wait_for_event_after(ws.home(), transition_cursor, MODAL_TIMEOUT, |event| { |
| 715 | event.is_close() && event.kind == *kind |
| 716 | }) |
| 717 | .map_err(|err| anyhow!("Esc did not close {kind}: {err:#}"))?; |
| 718 | transition_cursor = closed.len(); |
| 719 | assert_eq!( |
| 720 | last.depth, 0, |
| 721 | "Esc left the view stack at depth {} after closing {kind}", |
| 722 | last.depth |
| 723 | ); |
| 724 | |
| 725 | expect_text( |
| 726 | &mut harness, |
| 727 | COMPOSER_READY_TEXT, |
| 728 | MODAL_TIMEOUT, |
| 729 | &format!("composer after closing {kind}"), |
| 730 | )?; |
| 731 | } |
| 732 | |
| 733 | // The composer must be typable again after the whole sweep — a modal that |
| 734 | // closed but kept input focus is the failure this row exists to catch. |
| 735 | harness.send(keys::key::text("post-modal input"))?; |
| 736 | expect_text( |
| 737 | &mut harness, |
| 738 | "post-modal input", |
| 739 | KEY_TIMEOUT, |
| 740 | "composer input after the modal sweep", |
| 741 | )?; |
| 742 | |
| 743 | let _ = harness.shutdown(); |
| 744 | Ok(()) |
| 745 | } |
| 746 | |
| 747 | /// The help overlay is the only place the product advertises chords, and it |
| 748 | /// renders straight from `KEYBINDINGS`. Scraping it keeps this suite from |
| 749 | /// growing a second shortcut table that can drift: if a chord stops being |
| 750 | /// advertised, this row fails rather than silently testing a stale contract. |
| 751 | #[test] |
| 752 | fn help_overlay_advertises_the_chords_this_matrix_drives() -> Result<()> { |
| 753 | let _guard = matrix_lock(); |
| 754 | let ws = make_sealed_workspace()?; |
| 755 | let mut harness = boot(&ws, 44, 140)?; |
| 756 | |
| 757 | harness.send(keys::key::f1())?; |
| 758 | expect_frame( |
| 759 | &mut harness, |
| 760 | |frame| frame.contains("Ctrl+K") || frame.contains("F1"), |
| 761 | MODAL_TIMEOUT, |
| 762 | "help overlay never opened", |
| 763 | )?; |
| 764 | |
| 765 | // The overlay lists more rows than fit on any terminal, so each chord is |
| 766 | // brought into view through the overlay's own substring filter instead of |
| 767 | // being asserted against whatever happened to be scrolled into frame. |
| 768 | // Chords this file drives, plus the command fallbacks the catalog |
| 769 | // documents as guaranteed. macOS renders Alt chords with `⌥`, so only |
| 770 | // spellings that are stable on every platform are asserted here. |
| 771 | for advertised in ["F1", "Ctrl+K", "Enter", "/context", "/transcript"] { |
| 772 | harness.send(keys::key::text(advertised))?; |
| 773 | expect_text( |
| 774 | &mut harness, |
| 775 | advertised, |
| 776 | KEY_TIMEOUT, |
| 777 | &format!("help overlay no longer advertises {advertised}, but this matrix drives it"), |
| 778 | )?; |
| 779 | harness.send(keys::key::backspaces(advertised.chars().count()))?; |
| 780 | expect_frame( |
| 781 | &mut harness, |
| 782 | |frame| frame.contains("Ctrl+C") && frame.contains("Esc"), |
| 783 | KEY_TIMEOUT, |
| 784 | "help filter never cleared", |
| 785 | )?; |
| 786 | } |
| 787 | |
| 788 | // #440 / #3758: Ctrl+G and Ctrl+S stash a draft, and nothing else. If |
| 789 | // either ever appears beside send/queue/steer copy the advertised action |
| 790 | // is ambiguous and the running-turn contract stops being teachable. |
| 791 | for stash_chord in ["Ctrl+G", "Ctrl+S"] { |
| 792 | harness.send(keys::key::text(stash_chord))?; |
| 793 | expect_text( |
| 794 | &mut harness, |
| 795 | stash_chord, |
| 796 | KEY_TIMEOUT, |
| 797 | &format!("{stash_chord} is not advertised at all"), |
| 798 | )?; |
| 799 | let frame = harness.frame(); |
| 800 | let text = frame.text(); |
| 801 | for line in text.lines() { |
| 802 | if !line.contains(stash_chord) { |
| 803 | continue; |
| 804 | } |
| 805 | let lowered = line.to_ascii_lowercase(); |
| 806 | for forbidden in ["send", "queue", "steer", "submit"] { |
| 807 | assert!( |
| 808 | !lowered.contains(forbidden), |
| 809 | "{stash_chord} must advertise exactly one action (stash), got {line:?}" |
| 810 | ); |
| 811 | } |
| 812 | } |
| 813 | harness.send(keys::key::backspaces(stash_chord.chars().count()))?; |
| 814 | } |
| 815 | |
| 816 | harness.send(keys::key::esc())?; |
| 817 | expect_text( |
| 818 | &mut harness, |
| 819 | COMPOSER_READY_TEXT, |
| 820 | MODAL_TIMEOUT, |
| 821 | "composer after closing help", |
| 822 | )?; |
| 823 | |
| 824 | let _ = harness.shutdown(); |
| 825 | Ok(()) |
| 826 | } |
| 827 | |
| 828 | // --------------------------------------------------------------------------- |
| 829 | // Terminal-mode restoration |
| 830 | // --------------------------------------------------------------------------- |
| 831 | |
| 832 | /// Every exit path must hand the terminal back the way it found it. Checked |
| 833 | /// from the raw control stream, because a leaked alternate screen or a leaked |
| 834 | /// kitty keyboard flag is invisible on the rendered frame and only shows up in |
| 835 | /// the user's shell afterwards (#1583, #2494). |
| 836 | #[test] |
| 837 | fn terminal_modes_are_restored_on_every_exit_path() -> Result<()> { |
| 838 | let _guard = matrix_lock(); |
| 839 | |
| 840 | // Ctrl+D on an empty composer: the cooperative exit. |
| 841 | { |
| 842 | let ws = make_sealed_workspace()?; |
| 843 | let mut harness = boot(&ws, 40, 120)?; |
| 844 | harness.send(keys::key::ctrl_d())?; |
| 845 | let status = harness.wait_for_exit(EXIT_TIMEOUT); |
| 846 | harness.pump(); |
| 847 | assert_eq!( |
| 848 | status, |
| 849 | Some(0), |
| 850 | "Ctrl+D on an empty composer must exit cleanly\n{}", |
| 851 | harness.diagnostics() |
| 852 | ); |
| 853 | assert_no_leaked_modes(&harness, "Ctrl+D exit"); |
| 854 | } |
| 855 | |
| 856 | // SIGINT: the signal handler's emergency restore path. |
| 857 | { |
| 858 | let ws = make_sealed_workspace()?; |
| 859 | let mut harness = boot(&ws, 40, 120)?; |
| 860 | let pid = harness.pid().ok_or_else(|| anyhow!("no child pid"))?; |
| 861 | let signalled = std::process::Command::new("kill") |
| 862 | .args(["-INT", &pid.to_string()]) |
| 863 | .status()?; |
| 864 | assert!(signalled.success(), "could not signal the TUI child"); |
| 865 | harness.wait_for_exit(EXIT_TIMEOUT); |
| 866 | harness.pump(); |
| 867 | assert_no_leaked_modes(&harness, "SIGINT exit"); |
| 868 | } |
| 869 | |
| 870 | // A terminal that was never given the alternate screen still must not be |
| 871 | // left with mouse capture or bracketed paste on. |
| 872 | { |
| 873 | let ws = make_sealed_workspace()?; |
| 874 | let mut harness = spawn(&ws, CAPABILITY_PROFILES[2], 24, 80, "warn")?; |
| 875 | expect_text( |
| 876 | &mut harness, |
| 877 | COMPOSER_READY_TEXT, |
| 878 | BOOT_TIMEOUT, |
| 879 | "boot on the unknown-tier terminal", |
| 880 | )?; |
| 881 | harness.send(keys::key::ctrl_d())?; |
| 882 | harness.wait_for_exit(EXIT_TIMEOUT); |
| 883 | harness.pump(); |
| 884 | assert_no_leaked_modes(&harness, "unknown-tier Ctrl+D exit"); |
| 885 | } |
| 886 | |
| 887 | Ok(()) |
| 888 | } |
| 889 | |
| 890 | /// Guard on the guard: `MODES_THAT_MUST_NOT_LEAK` has to actually cover the |
| 891 | /// modes the TUI turns on, or `assert_no_leaked_modes` passes vacuously. |
| 892 | #[test] |
| 893 | fn the_leak_guard_covers_the_modes_the_tui_enables() -> Result<()> { |
| 894 | let _guard = matrix_lock(); |
| 895 | let ws = make_sealed_workspace()?; |
| 896 | let mut harness = boot(&ws, 40, 120)?; |
| 897 | // Mouse capture is established at startup; touch the input path so the |
| 898 | // sample is taken after the TUI has fully settled its modes. |
| 899 | harness.send(keys::focus::gained())?; |
| 900 | expect_text( |
| 901 | &mut harness, |
| 902 | COMPOSER_READY_TEXT, |
| 903 | KEY_TIMEOUT, |
| 904 | "composer before sampling modes", |
| 905 | )?; |
| 906 | |
| 907 | let ledger = harness.terminal_modes(); |
| 908 | let covered: Vec<u16> = MODES_THAT_MUST_NOT_LEAK |
| 909 | .iter() |
| 910 | .map(|(number, _)| *number) |
| 911 | .filter(|number| ledger.was_ever_enabled(*number)) |
| 912 | .collect(); |
| 913 | assert!( |
| 914 | covered.contains(&mode::ALT_SCREEN) |
| 915 | && covered.contains(&mode::BRACKETED_PASTE) |
| 916 | && covered.contains(&mode::FOCUS), |
| 917 | "the running TUI did not enable the modes the exit guard checks; \ |
| 918 | covered={covered:?}\n{}", |
| 919 | ledger.debug_dump() |
| 920 | ); |
| 921 | |
| 922 | let _ = harness.shutdown(); |
| 923 | Ok(()) |
| 924 | } |
| 925 |