返回 CodeWhale
terminal_matrix_qa.rs
根目录 / crates / tui / tests / terminal_matrix_qa.rs
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
925 lines RUST