返回 CodeWhale
launch_card_pty.rs
根目录 / crates / tui / tests / cucumber / launch_card_pty.rs
1 //! Launch actions must work through the real input loop, including Enter
2 //! after a mouse click. All state is sealed; no provider is contacted.
3
4 use std::time::Duration;
5
6 use super::qa_harness;
7 use qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace};
8 use qa_harness::keys;
9
10 const WAIT: Duration = Duration::from_secs(15);
11 const SIZES: [(u16, u16); 5] = [(12, 40), (16, 60), (24, 80), (32, 100), (40, 140)];
12 const TITLE: &str = "Recent proof";
13 const SAVED_TEXT: &str = "Restored conversation proof";
14
15 #[test]
16 #[ignore = "opt-in website media; empty isolated session, no provider calls"]
17 fn website_current_terminal_capture() {
18 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
19 let (_workspace, mut tui) =
20 start_with_options(24, 100, false, &[], Some("shoreline"), true, false);
21 // Capture only actual application output: no fabricated history, usage,
22 // connected tools, model response or completed work.
23 tui.wait_for_idle(Duration::from_secs(4), WAIT).unwrap();
24 capture(&mut tui, "website-home");
25 tui.shutdown();
26 }
27
28 fn start(rows: u16, cols: u16, with_mcp: bool) -> (SealedWorkspace, Harness) {
29 start_titled(rows, cols, with_mcp, TITLE)
30 }
31
32 fn start_titled(rows: u16, cols: u16, with_mcp: bool, title: &str) -> (SealedWorkspace, Harness) {
33 start_with_titles(rows, cols, with_mcp, &[title])
34 }
35
36 fn start_with_titles(
37 rows: u16,
38 cols: u16,
39 with_mcp: bool,
40 titles: &[&str],
41 ) -> (SealedWorkspace, Harness) {
42 start_with_theme(rows, cols, with_mcp, titles, None)
43 }
44
45 fn start_with_theme(
46 rows: u16,
47 cols: u16,
48 with_mcp: bool,
49 titles: &[&str],
50 theme: Option<&str>,
51 ) -> (SealedWorkspace, Harness) {
52 start_with_options(rows, cols, with_mcp, titles, theme, false, false)
53 }
54
55 fn start_with_options(
56 rows: u16,
57 cols: u16,
58 with_mcp: bool,
59 titles: &[&str],
60 theme: Option<&str>,
61 animated: bool,
62 no_color: bool,
63 ) -> (SealedWorkspace, Harness) {
64 let workspace = make_sealed_workspace().unwrap();
65 let trust = workspace.workspace().join(".deepseek");
66 let sessions = workspace.home().join(".codewhale/sessions");
67 for directory in [&trust, &sessions] {
68 std::fs::create_dir_all(directory).unwrap();
69 }
70 let mut fixtures = vec![
71 (workspace.home().join(".codewhale/.onboarded"), Vec::new()),
72 (trust.join("trusted"), Vec::new()),
73 ];
74 if let Some(theme) = theme {
75 fixtures.push((
76 workspace.home().join(".codewhale/settings.toml"),
77 format!("theme = {theme:?}\n").into_bytes(),
78 ));
79 }
80 for (index, title) in titles.iter().enumerate() {
81 let id = format!(
82 "11111111-2222-4333-8444-{:012}",
83 555555555555u64 + index as u64
84 );
85 let session = serde_json::json!({
86 "schema_version": 1,
87 "metadata": {
88 "id": id,
89 "title": title,
90 "created_at": "2026-09-19T00:00:00Z",
91 "updated_at": format!("2026-09-19T00:00:{:02}Z", 59usize.saturating_sub(index)),
92 "message_count": 1,
93 "total_tokens": 0,
94 "model": "deepseek-flash",
95 "model_provider": "deepseek",
96 "workspace": workspace.workspace()
97 },
98 "messages": [{"role": "user", "content": [{"type": "text", "text": SAVED_TEXT}]}],
99 "system_prompt": null
100 });
101 fixtures.push((
102 sessions.join(format!("{id}.json")),
103 serde_json::to_vec(&session).unwrap(),
104 ));
105 }
106 if with_mcp {
107 // A local failing server gives the summary a real row without any network.
108 let mcp = serde_json::json!({"mcpServers": {"launch-proof": {
109 "command": "/usr/bin/false", "required": true
110 }}});
111 fixtures.push((
112 workspace.home().join(".codewhale/mcp.json"),
113 serde_json::to_vec(&mcp).unwrap(),
114 ));
115 }
116 for (path, contents) in fixtures {
117 std::fs::write(path, contents).unwrap();
118 }
119
120 let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui"))
121 .cwd(workspace.workspace())
122 .clear_env()
123 .seal_home(workspace.home())
124 .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
125 .env("CODEWHALE_NO_UPDATE_CHECK", "1")
126 .env("NO_ANIMATIONS", if animated { "0" } else { "1" })
127 .env("COLORTERM", "truecolor")
128 .env("NO_COLOR", if no_color { "1" } else { "" })
129 .args([
130 "--workspace",
131 workspace.workspace().to_str().unwrap(),
132 "--no-project-config",
133 "--fresh",
134 "--mouse-capture",
135 ])
136 .size(rows, cols)
137 .spawn()
138 .unwrap();
139 wait(&mut tui, "Choose your model provider");
140 tui.send(keys::key::ctrl('o')).unwrap();
141 wait(&mut tui, "You're ready.");
142 tui.send(keys::key::enter()).unwrap();
143 wait(&mut tui, "New session");
144 if animated {
145 return (workspace, tui);
146 }
147 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
148 tui.send(keys::key::ctrl('u')).unwrap();
149 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
150 (workspace, tui)
151 }
152
153 fn wait(tui: &mut Harness, text: &str) {
154 if let Err(error) = tui.wait_for(|frame| frame.contains(text), WAIT) {
155 let transcript = tui.transcript();
156 let tail = &transcript[transcript.len().saturating_sub(4096)..];
157 panic!(
158 "waiting for {text:?}: {error}\n{}\nPTY tail: {:?}",
159 tui.diagnostics(),
160 String::from_utf8_lossy(tail)
161 );
162 }
163 }
164
165 fn click_text(tui: &mut Harness, text: &str) {
166 tui.pump();
167 let (row, col) = tui
168 .frame()
169 .find_text(text)
170 .unwrap_or_else(|| panic!("missing click target {text:?}\n{}", tui.diagnostics()));
171 tui.send(keys::mouse::click(row, col)).unwrap();
172 }
173
174 #[test]
175 fn local_slash_navigation_does_not_create_rewindable_user_turns() {
176 let (_workspace, mut tui) = start_with_titles(24, 80, false, &[]);
177 // The first command leaves home; the others use the active-session path.
178 for (command, title) in [
179 ("/settings", "Config"),
180 ("/skills", "Extensions"),
181 ("/mcp", "Extensions"),
182 ] {
183 tui.type_line(command).unwrap();
184 wait(&mut tui, title);
185 tui.send(keys::key::esc()).unwrap();
186 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
187 assert!(
188 !tui.frame()
189 .text()
190 .lines()
191 .take(18)
192 .any(|line| line.contains(command)),
193 "navigation leaked into transcript above the composer: {}",
194 tui.diagnostics()
195 );
196 }
197 tui.send(keys::key::esc()).unwrap();
198 tui.send(keys::key::esc()).unwrap();
199 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
200 assert!(
201 !tui.frame().contains("Backtrack preview"),
202 "view navigation became a rewindable turn: {}",
203 tui.diagnostics()
204 );
205 tui.shutdown();
206 }
207
208 #[test]
209 fn raw_slash_input_reenables_its_submit_cue_without_another_key() {
210 let (_workspace, mut tui) = start_with_titles(24, 80, false, &[]);
211 tui.send("/mcp").unwrap();
212 wait(&mut tui, "enter:run");
213 wait(&mut tui, "[↵]");
214 tui.send(keys::key::enter()).unwrap();
215 wait(&mut tui, "Extensions");
216 tui.shutdown();
217 }
218
219 #[test]
220 fn launch_recent_click_then_enter_resumes_without_another_mouse_event() {
221 for (rows, cols) in SIZES {
222 let (_workspace, mut tui) = start(rows, cols, false);
223 wait(&mut tui, TITLE);
224 capture(&mut tui, "home");
225 tui.send(keys::key::down()).unwrap();
226 tui.send(keys::key::down()).unwrap();
227 capture(&mut tui, "selected");
228 click_text(&mut tui, TITLE);
229 wait(&mut tui, "Resume");
230 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
231 capture(&mut tui, "confirm");
232 tui.send(keys::key::enter()).unwrap();
233 // No pointer motion follows Enter: the accepted action must run now.
234 wait(&mut tui, SAVED_TEXT);
235 if cols >= 80 {
236 wait(&mut tui, "Resumed:");
237 }
238 assert!(
239 !tui.frame().contains("Session loaded from"),
240 "resume should not add a technical path receipt to the conversation"
241 );
242 capture(&mut tui, "conversation");
243 tui.shutdown();
244 }
245 }
246
247 #[test]
248 fn launch_mcp_summary_opens_manager_by_click_and_keyboard() {
249 for (rows, cols) in SIZES {
250 let (_workspace, mut tui) = start(rows, cols, true);
251 wait(&mut tui, "MCP");
252 capture(&mut tui, "home-mcp");
253 click_text(&mut tui, "MCP");
254 wait(&mut tui, "Extensions");
255 wait(&mut tui, "launch-proof");
256 capture(&mut tui, "mcp");
257 tui.send(keys::key::esc()).unwrap();
258 wait(&mut tui, "New session");
259 // New session, the recent row (or compact See all), then MCP.
260 for _ in 0..3 {
261 tui.send(keys::key::down()).unwrap();
262 }
263 tui.send(keys::key::enter()).unwrap();
264 wait(&mut tui, "Extensions");
265 wait(&mut tui, "launch-proof");
266 tui.shutdown();
267 }
268 }
269
270 #[test]
271 fn launch_resume_buttons_support_mouse_cancel_and_keyboard_choice() {
272 for (rows, cols) in SIZES {
273 let (_workspace, mut tui) = start(rows, cols, false);
274 click_text(&mut tui, TITLE);
275 wait(&mut tui, "resume");
276 click_text(&mut tui, "cancel");
277 wait(&mut tui, "New session");
278 assert!(!tui.frame().contains(SAVED_TEXT));
279 click_text(&mut tui, TITLE);
280 wait(&mut tui, "resume");
281 tui.send(keys::key::tab()).unwrap();
282 tui.send(keys::key::enter()).unwrap();
283 wait(&mut tui, "New session");
284 assert!(!tui.frame().contains(SAVED_TEXT));
285 click_text(&mut tui, TITLE);
286 wait(&mut tui, "resume");
287 click_text(&mut tui, "resume");
288 wait(&mut tui, SAVED_TEXT);
289 tui.shutdown();
290 }
291 }
292
293 /// Optional review evidence from the real PTY, keeping cell colors rather
294 /// than relying on symbol-only goldens. The viewer supplies terminal fonts.
295 pub(super) fn capture(tui: &mut Harness, name: &str) {
296 let Some(directory) = std::env::var_os("QA_LAUNCH_CAPTURE_DIR") else {
297 return;
298 };
299 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
300 let frame = tui.frame();
301 let directory = std::path::PathBuf::from(directory);
302 std::fs::create_dir_all(&directory).unwrap();
303 let path = directory.join(format!("{name}-{}x{}.json", frame.cols(), frame.rows()));
304 std::fs::write(
305 path,
306 serde_json::to_vec_pretty(&frame.capture_cells()).unwrap(),
307 )
308 .unwrap();
309 }
310
311 #[test]
312 #[ignore = "opt-in visual evidence; writes only with QA_LAUNCH_CAPTURE_DIR"]
313 fn workbench_settings_visual_evidence() {
314 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
315 for (command, title, name) in [
316 ("/model", "route ·", "models"),
317 ("/provider", "Provider", "providers"),
318 ("/fleet", "Coordinator", "fleet"),
319 ("/plugin", "Extensions", "plugins"),
320 ("/config", "Config", "settings"),
321 ("/statusline", "Status", "statusline"),
322 ] {
323 for (rows, cols) in SIZES {
324 let (_workspace, mut tui) = start(rows, cols, true);
325 tui.paste(command).unwrap();
326 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
327 tui.send(keys::key::enter()).unwrap();
328 wait(&mut tui, title);
329 capture(&mut tui, name);
330 if name == "providers" {
331 tui.send(keys::key::alt('v')).unwrap();
332 wait(&mut tui, "DeepSeek · Open details");
333 capture(&mut tui, "provider-details");
334 tui.send(keys::key::esc()).unwrap();
335 wait(&mut tui, "Provider");
336 }
337 tui.shutdown();
338 }
339 }
340 }
341
342 #[test]
343 #[ignore = "opt-in populated launch evidence; fixture sessions, no provider calls"]
344 fn workbench_populated_home_visual_evidence() {
345 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
346 for (rows, cols) in SIZES {
347 let (_workspace, mut tui) = start_with_titles(
348 rows,
349 cols,
350 true,
351 &[
352 "Polish the release notes",
353 "Investigate a provider timeout",
354 "Review the plugin setup flow",
355 ],
356 );
357 capture(&mut tui, "home-populated");
358 tui.send(keys::key::down()).unwrap();
359 tui.send(keys::key::down()).unwrap();
360 capture(&mut tui, "home-populated-selected");
361 tui.shutdown();
362 }
363 }
364
365 #[test]
366 fn launch_long_resume_title_preserves_warning_and_truthful_enter_hint() {
367 let title = "Investigate provider timeouts and connection failures across multiple accounts, preserve the original credentials, and verify every saved session can still be restored after the upgrade";
368 for (rows, cols) in SIZES {
369 let (_workspace, mut tui) = start_titled(rows, cols, false, title);
370 click_text(&mut tui, "Investigate provider");
371 wait(&mut tui, "resume");
372 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
373 capture(&mut tui, "confirm-long");
374 let text = tui
375 .frame()
376 .text()
377 .chars()
378 .map(|ch| {
379 if ('\u{2500}'..='\u{257f}').contains(&ch) {
380 ' '
381 } else {
382 ch
383 }
384 })
385 .collect::<String>()
386 .split_whitespace()
387 .collect::<Vec<_>>()
388 .join(" ");
389 assert!(
390 text.contains("This replaces the current context with that session's history."),
391 "{text}"
392 );
393 tui.send(keys::key::tab()).unwrap();
394 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
395 let text = tui.frame().text();
396 assert!(text.contains("cancel Enter"), "{text}");
397 assert!(!text.contains("resume Enter"), "{text}");
398 capture(&mut tui, "confirm-cancel");
399 tui.send(keys::key::enter()).unwrap();
400 wait(&mut tui, "New session");
401 assert!(!tui.frame().contains(SAVED_TEXT));
402 tui.shutdown();
403 }
404 }
405
406 #[test]
407 #[ignore = "opt-in all-theme evidence; fixture sessions, no provider calls"]
408 fn workbench_every_theme_visual_evidence() {
409 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
410 for theme in codewhale_palette::SELECTABLE_THEMES {
411 let (_workspace, mut tui) = start_with_theme(24, 80, true, &[TITLE], Some(theme.name()));
412 capture(&mut tui, &format!("theme-{}-home", theme.name()));
413 tui.paste("/statusline").unwrap();
414 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
415 tui.send(keys::key::enter()).unwrap();
416 wait(&mut tui, "Status");
417 capture(&mut tui, &format!("theme-{}-statusline", theme.name()));
418 tui.shutdown();
419 }
420 }
421
422 #[test]
423 #[ignore = "opt-in real launch animation capture; fixture state, no provider calls"]
424 fn workbench_whale_reveal_visual_evidence() {
425 let directory = std::path::PathBuf::from(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").unwrap());
426 std::fs::create_dir_all(&directory).unwrap();
427 let (_workspace, mut tui) =
428 start_with_options(32, 100, false, &[TITLE], Some("shoreline"), true, false);
429 let start = std::time::Instant::now();
430 for index in 0..16 {
431 let frame = tui.frame();
432 assert!(
433 frame.text().contains("New session"),
434 "controls must remain usable during reveal"
435 );
436 std::fs::write(
437 directory.join(format!("reveal-{index:02}-100x32.json")),
438 serde_json::to_vec_pretty(&frame.capture_cells()).unwrap(),
439 )
440 .unwrap();
441 std::thread::sleep(Duration::from_millis(50));
442 }
443 eprintln!("Captured launch reveal over {:?}", start.elapsed());
444 tui.shutdown();
445 }
446
447 /// Record temporal evidence from the real terminal, including its idle settle.
448 #[test]
449 #[ignore = "opt-in Underwater motion capture; isolated fixture, no provider calls"]
450 fn underwater_motion_visual_evidence() {
451 use std::io::Write;
452 let directory = std::path::PathBuf::from(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").unwrap());
453 std::fs::create_dir_all(&directory).unwrap();
454 for (rows, cols) in [(24, 80), (36, 120)] {
455 let (_workspace, mut tui) =
456 start_with_options(rows, cols, false, &[TITLE], Some("underwater"), true, false);
457 // Home intentionally gives its brief whale reveal the stage. Sea life
458 // lives in the conversation field, so enter a fresh offline session.
459 tui.send(keys::key::ctrl('u')).unwrap();
460 click_text(&mut tui, "New session");
461 wait(&mut tui, "What do you want to accomplish?");
462 let file =
463 std::fs::File::create(directory.join(format!("ocean-{cols}x{rows}.jsonl.gz"))).unwrap();
464 let mut output = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
465 let start = std::time::Instant::now();
466 for index in 0..360u64 {
467 let frame = tui.frame();
468 assert!(
469 frame.contains("Type a message")
470 && frame.contains("What do you want to accomplish?"),
471 "motion cannot displace the conversation or composer"
472 );
473 serde_json::to_writer(
474 &mut output,
475 &serde_json::json!({
476 "elapsed_ms": start.elapsed().as_millis(),
477 "frame": frame.capture_cells()
478 }),
479 )
480 .unwrap();
481 output.write_all(b"\n").unwrap();
482 let target = Duration::from_millis((index + 1) * 33);
483 if let Some(remaining) = target.checked_sub(start.elapsed()) {
484 std::thread::sleep(remaining);
485 }
486 }
487 output.finish().unwrap();
488 tui.shutdown();
489 }
490 }
491
492 /// Exercise the visible catalog controls and provider search through the
493 /// input decoder. This only browses fixture state; it never applies a route.
494 #[test]
495 fn settings_catalog_controls_and_provider_search_work_with_mouse_and_keyboard() {
496 for (rows, cols) in SIZES {
497 let (_workspace, mut tui) = start(rows, cols, false);
498 tui.paste("/provider").unwrap();
499 tui.send(keys::key::enter()).unwrap();
500 wait(&mut tui, "Provider");
501 click_text(&mut tui, "browse all");
502 wait(&mut tui, "configured");
503 tui.send("/Anthropic").unwrap();
504 wait(&mut tui, "search: Anthropic");
505 capture(&mut tui, "providers-search");
506 tui.send(keys::key::esc()).unwrap();
507 wait(&mut tui, "Provider");
508 capture(&mut tui, "providers-catalog");
509 tui.send(keys::key::esc()).unwrap();
510 // Let the standalone Escape decode before starting a bracketed paste.
511 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
512 tui.paste("/model").unwrap();
513 tui.send(keys::key::enter()).unwrap();
514 wait(&mut tui, "route ·");
515 click_text(&mut tui, "browse catalog");
516 wait(&mut tui, "catalog");
517 capture(&mut tui, "models-catalog");
518 tui.send(keys::key::esc()).unwrap();
519 tui.shutdown();
520 }
521 }
522
523 #[test]
524 fn fleet_roles_open_the_shared_model_picker_and_escape_returns_to_the_same_role() {
525 for (rows, cols) in SIZES {
526 let (_workspace, mut tui) = start(rows, cols, false);
527 tui.paste("/fleet").unwrap();
528 tui.send(keys::key::enter()).unwrap();
529 wait(&mut tui, "Coordinator");
530 capture(&mut tui, "fleet-assignments");
531 tui.send(keys::key::enter()).unwrap();
532 wait(&mut tui, "Model · Coordinator");
533 wait(&mut tui, "Current session");
534 capture(&mut tui, "fleet-coordinator-model");
535 tui.send(keys::key::esc()).unwrap();
536 wait(&mut tui, "saved teams");
537 tui.send(keys::key::down()).unwrap();
538 tui.send(keys::key::enter()).unwrap();
539 wait(&mut tui, "Model · manager");
540 capture(&mut tui, "fleet-role-model");
541 tui.send("search-proof").unwrap();
542 wait(&mut tui, "search-proof");
543 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
544 tui.send(keys::key::esc()).unwrap();
545 tui.wait_for(|frame| !frame.contains("search-proof"), WAIT)
546 .unwrap();
547 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
548 tui.send(keys::key::esc()).unwrap();
549 wait(&mut tui, "saved teams");
550 tui.send(keys::key::enter()).unwrap();
551 wait(&mut tui, "Model · manager");
552 // Following Coordinator is a selectable local choice even without credentials.
553 tui.send(keys::key::enter()).unwrap();
554 wait(&mut tui, "Personal");
555 capture(&mut tui, "fleet-role-destination");
556 tui.shutdown();
557 }
558 }
559
560 #[test]
561 fn no_color_keeps_home_navigation_and_submit_cues_without_color() {
562 let sgr = regex::Regex::new(r"\x1b\[([0-9;:]*)m").unwrap();
563 for (rows, cols) in SIZES {
564 let (_workspace, mut tui) =
565 start_with_options(rows, cols, false, &[TITLE], Some("shoreline"), false, true);
566 wait(&mut tui, "[·]");
567 tui.send(keys::key::down()).unwrap();
568 tui.send(keys::key::down()).unwrap();
569 capture(&mut tui, "no-color-selected");
570 tui.send(keys::key::enter()).unwrap();
571 wait(&mut tui, "Resume");
572 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
573 tui.send(keys::key::enter()).unwrap();
574 wait(&mut tui, SAVED_TEXT);
575 tui.send("monochrome draft").unwrap();
576 wait(&mut tui, "[↵]");
577 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
578 capture(&mut tui, "no-color-draft");
579
580 for row in 0..rows {
581 for col in 0..cols {
582 assert_eq!(
583 tui.frame().colors_at(row, col),
584 Some((
585 qa_harness::frame::Color::Default,
586 qa_harness::frame::Color::Default
587 )),
588 "{cols}x{rows} cell ({row}, {col}) added a color"
589 );
590 }
591 }
592 // Inspect the whole emitted stream, not just its last rendered frame.
593 let transcript = tui.transcript();
594 let output = String::from_utf8_lossy(&transcript);
595 for codes in sgr.captures_iter(&output) {
596 for code in codes[1]
597 .split([';', ':'])
598 .filter_map(|code| code.parse::<u16>().ok())
599 {
600 assert!(
601 !matches!(code, 30..=38 | 40..=48 | 58 | 90..=97 | 100..=107),
602 "{cols}x{rows} emitted color SGR {:?}",
603 &codes[0]
604 );
605 }
606 }
607 tui.shutdown();
608 }
609 }
610
611 #[test]
612 fn home_returns_to_the_same_conversation_by_escape_click_and_typing() {
613 for (rows, cols) in SIZES {
614 let (_workspace, mut tui) = start(rows, cols, false);
615 click_text(&mut tui, TITLE);
616 wait(&mut tui, "Resume");
617 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
618 tui.send(keys::key::enter()).unwrap();
619 wait(&mut tui, SAVED_TEXT);
620 for return_path in ["escape", "click", "type"] {
621 tui.paste("/home").unwrap();
622 tui.send(keys::key::enter()).unwrap();
623 wait(&mut tui, "Back to conversation");
624 capture(&mut tui, "home-return");
625 match return_path {
626 "escape" => tui.send(keys::key::esc()).unwrap(),
627 "click" => click_text(&mut tui, "Back to conversation"),
628 _ => tui.send("draft stays here").unwrap(),
629 }
630 tui.wait_for(|frame| !frame.contains("Back to conversation"), WAIT)
631 .unwrap();
632 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
633 if return_path == "type" {
634 wait(&mut tui, "draft stays here");
635 capture(&mut tui, "home-return-draft");
636 tui.send(keys::key::ctrl('u')).unwrap();
637 }
638 // Slash commands add transcript rows. At 40x12 the original
639 // message is now above the viewport, so inspect scrollback.
640 tui.send(keys::key::page_up()).unwrap();
641 wait(&mut tui, SAVED_TEXT);
642 tui.send(keys::key::alt('G')).unwrap();
643 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
644 }
645 tui.paste("/overview").unwrap();
646 tui.send(keys::key::enter()).unwrap();
647 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
648 // The dashboard is longer than a short transcript viewport.
649 for _ in 0..20 {
650 if tui.frame().contains("Quick Actions") {
651 break;
652 }
653 tui.send(keys::key::page_up()).unwrap();
654 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
655 }
656 wait(&mut tui, "Quick Actions");
657 tui.shutdown();
658 }
659 }
660
660 lines RUST