返回 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::{
8 Harness, SealedWorkspace, make_sealed_workspace, make_sealed_workspace_in_home,
9 };
10 use qa_harness::keys;
11
12 const WAIT: Duration = Duration::from_secs(15);
13 const SIZES: [(u16, u16); 5] = [(12, 40), (16, 60), (24, 80), (32, 100), (40, 140)];
14 const TITLE: &str = "Recent proof";
15 const SAVED_TEXT: &str = "Restored conversation proof";
16
17 /// Published codewhale.net terminal media. Default Underwater theme, no
18 /// motion, a home-relative workspace, and only states a fresh install really
19 /// reaches: the first-run provider picker, home, a typed draft, /help, and
20 /// the empty Fleet and Tasks work bar.
21 #[test]
22 #[ignore = "opt-in website media; empty isolated session, no provider calls"]
23 fn website_current_terminal_capture() {
24 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
25 let workspace = make_sealed_workspace_in_home("my-project").unwrap();
26 let (_workspace, mut tui) = start_in(
27 workspace,
28 Launch {
29 rows: 24,
30 cols: 100,
31 // The fixture Ctrl+U leaves a "Ctrl+Z restores" receipt that
32 // never expires on an idle, motionless screen.
33 keep_composer: true,
34 ..Launch::default()
35 },
36 |tui| capture(tui, "website-provider-picker"),
37 );
38 // Capture only actual application output: no fabricated history, usage,
39 // connected tools, model response or completed work.
40 tui.wait_for_idle(Duration::from_secs(1), WAIT).unwrap();
41 assert_website_frame(&mut tui);
42 capture(&mut tui, "website-home");
43
44 // Backspace, not Ctrl+U: clearing by Ctrl+U raises a receipt that would
45 // be published with the frame.
46 let prompt = "Add a --json flag to the export command";
47 tui.send(keys::key::backspaces(80)).unwrap();
48 wait(&mut tui, "Type a message");
49 tui.send(prompt).unwrap();
50 wait(&mut tui, prompt);
51 wait(&mut tui, "[↵]");
52 assert_website_frame(&mut tui);
53 capture(&mut tui, "website-composer");
54 tui.send(keys::key::backspaces(prompt.len())).unwrap();
55 wait(&mut tui, "Type a message");
56
57 tui.paste("/help").unwrap();
58 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
59 tui.send(keys::key::enter()).unwrap();
60 wait(&mut tui, "/model");
61 capture(&mut tui, "website-help");
62 tui.shutdown();
63
64 // The work bar at the taller size the site shows it, opened by the
65 // advertised chords on an emptied composer: Fleet (Ctrl+]), then Tasks.
66 let workspace = make_sealed_workspace_in_home("my-project").unwrap();
67 let (_workspace, mut tui) = start_in(
68 workspace,
69 Launch {
70 rows: 32,
71 cols: 100,
72 keep_composer: true,
73 ..Launch::default()
74 },
75 |_| {},
76 );
77 tui.wait_for_idle(Duration::from_secs(1), WAIT).unwrap();
78 tui.send(keys::key::backspaces(80)).unwrap();
79 wait(&mut tui, "Type a message");
80 assert_website_frame(&mut tui);
81 tui.send([0x1d]).unwrap();
82 wait(&mut tui, "no agents have run this session");
83 capture(&mut tui, "website-workbar-fleet");
84 tui.send(b"\x1b[9;6u").unwrap();
85 wait(&mut tui, "no to-dos yet");
86 capture(&mut tui, "website-workbar");
87 tui.shutdown();
88 }
89
90 /// The published frame names the project the way a user's shell would and
91 /// never leaks the sealed tempdir.
92 fn assert_website_frame(tui: &mut Harness) {
93 tui.pump();
94 let text = tui.frame().text();
95 assert!(
96 text.contains("~/my-project") && !text.contains(".tmp"),
97 "website frame must show ~/my-project and no tempdir: {}",
98 tui.diagnostics()
99 );
100 }
101
102 fn start(rows: u16, cols: u16, with_mcp: bool) -> (SealedWorkspace, Harness) {
103 start_titled(rows, cols, with_mcp, TITLE)
104 }
105
106 fn start_titled(rows: u16, cols: u16, with_mcp: bool, title: &str) -> (SealedWorkspace, Harness) {
107 start_with_titles(rows, cols, with_mcp, &[title])
108 }
109
110 fn start_with_titles(
111 rows: u16,
112 cols: u16,
113 with_mcp: bool,
114 titles: &[&str],
115 ) -> (SealedWorkspace, Harness) {
116 start_with_theme(rows, cols, with_mcp, titles, None)
117 }
118
119 fn start_with_theme(
120 rows: u16,
121 cols: u16,
122 with_mcp: bool,
123 titles: &[&str],
124 theme: Option<&str>,
125 ) -> (SealedWorkspace, Harness) {
126 start_with_options(rows, cols, with_mcp, titles, theme, false, false)
127 }
128
129 fn start_with_options(
130 rows: u16,
131 cols: u16,
132 with_mcp: bool,
133 titles: &[&str],
134 theme: Option<&str>,
135 animated: bool,
136 no_color: bool,
137 ) -> (SealedWorkspace, Harness) {
138 start_in(
139 make_sealed_workspace().unwrap(),
140 Launch {
141 rows,
142 cols,
143 with_mcp,
144 titles,
145 theme,
146 animated,
147 no_color,
148 keep_composer: false,
149 },
150 |_| {},
151 )
152 }
153
154 #[derive(Default)]
155 struct Launch<'a> {
156 rows: u16,
157 cols: u16,
158 with_mcp: bool,
159 titles: &'a [&'a str],
160 theme: Option<&'a str>,
161 animated: bool,
162 no_color: bool,
163 /// Skip the post-launch Ctrl+U that empties the composer.
164 keep_composer: bool,
165 }
166
167 /// Launch into `workspace` and walk first-run onboarding to home.
168 /// `on_provider_picker` sees the real first-run provider picker.
169 fn start_in(
170 workspace: SealedWorkspace,
171 launch: Launch<'_>,
172 on_provider_picker: impl FnOnce(&mut Harness),
173 ) -> (SealedWorkspace, Harness) {
174 let Launch {
175 rows,
176 cols,
177 with_mcp,
178 titles,
179 theme,
180 animated,
181 no_color,
182 keep_composer,
183 } = launch;
184 let trust = workspace.workspace().join(".deepseek");
185 let sessions = workspace.home().join(".codewhale/sessions");
186 for directory in [&trust, &sessions] {
187 std::fs::create_dir_all(directory).unwrap();
188 }
189 let mut fixtures = vec![
190 (workspace.home().join(".codewhale/.onboarded"), Vec::new()),
191 (trust.join("trusted"), Vec::new()),
192 ];
193 if let Some(theme) = theme {
194 fixtures.push((
195 workspace.home().join(".codewhale/settings.toml"),
196 format!("theme = {theme:?}\n").into_bytes(),
197 ));
198 }
199 for (index, title) in titles.iter().enumerate() {
200 let id = format!(
201 "11111111-2222-4333-8444-{:012}",
202 555555555555u64 + index as u64
203 );
204 let session = serde_json::json!({
205 "schema_version": 1,
206 "metadata": {
207 "id": id,
208 "title": title,
209 "created_at": "2026-09-19T00:00:00Z",
210 "updated_at": format!("2026-09-19T00:00:{:02}Z", 59usize.saturating_sub(index)),
211 "message_count": 1,
212 "total_tokens": 0,
213 "model": "deepseek-flash",
214 "model_provider": "deepseek",
215 "workspace": workspace.workspace()
216 },
217 "messages": [{"role": "user", "content": [{"type": "text", "text": SAVED_TEXT}]}],
218 "system_prompt": null
219 });
220 fixtures.push((
221 sessions.join(format!("{id}.json")),
222 serde_json::to_vec(&session).unwrap(),
223 ));
224 }
225 if with_mcp {
226 // A local failing server gives the summary a real row without any network.
227 let mcp = serde_json::json!({"mcpServers": {"launch-proof": {
228 "command": "/usr/bin/false", "required": true
229 }}});
230 fixtures.push((
231 workspace.home().join(".codewhale/mcp.json"),
232 serde_json::to_vec(&mcp).unwrap(),
233 ));
234 }
235 for (path, contents) in fixtures {
236 std::fs::write(path, contents).unwrap();
237 }
238
239 let mut tui = Harness::builder(Harness::codewhale_binary())
240 .cwd(workspace.workspace())
241 .clear_env()
242 .seal_home(workspace.home())
243 .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
244 .env("CODEWHALE_NO_UPDATE_CHECK", "1")
245 .env("CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE", "1")
246 .env("NO_ANIMATIONS", if animated { "0" } else { "1" })
247 .env("COLORTERM", "truecolor")
248 .env("NO_COLOR", if no_color { "1" } else { "" })
249 .args([
250 "--workspace",
251 workspace.workspace().to_str().unwrap(),
252 "--no-project-config",
253 "--fresh",
254 "--mouse-capture",
255 ])
256 .size(rows, cols)
257 .spawn()
258 .unwrap();
259 wait(&mut tui, "Choose your model provider");
260 on_provider_picker(&mut tui);
261 tui.send(keys::key::ctrl('o')).unwrap();
262 wait(&mut tui, "You're ready.");
263 tui.send(keys::key::enter()).unwrap();
264 wait(&mut tui, "New session");
265 if animated || keep_composer {
266 return (workspace, tui);
267 }
268 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
269 tui.send(keys::key::ctrl('u')).unwrap();
270 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
271 (workspace, tui)
272 }
273
274 fn wait(tui: &mut Harness, text: &str) {
275 if let Err(error) = tui.wait_for(|frame| frame.contains(text), WAIT) {
276 let transcript = tui.transcript();
277 let tail = &transcript[transcript.len().saturating_sub(4096)..];
278 panic!(
279 "waiting for {text:?}: {error}\n{}\nPTY tail: {:?}",
280 tui.diagnostics(),
281 String::from_utf8_lossy(tail)
282 );
283 }
284 }
285
286 fn click_text(tui: &mut Harness, text: &str) {
287 tui.pump();
288 let (row, col) = tui
289 .frame()
290 .find_text(text)
291 .unwrap_or_else(|| panic!("missing click target {text:?}\n{}", tui.diagnostics()));
292 tui.send(keys::mouse::click(row, col)).unwrap();
293 }
294
295 #[test]
296 fn local_slash_navigation_does_not_create_rewindable_user_turns() {
297 let (_workspace, mut tui) = start_with_titles(24, 80, false, &[]);
298 // The first command leaves home; the others use the active-session path.
299 for (command, title) in [
300 ("/settings", "Settings"),
301 ("/skills", "Extensions"),
302 ("/mcp", "Extensions"),
303 ] {
304 tui.type_line(command).unwrap();
305 wait(&mut tui, title);
306 tui.send(keys::key::esc()).unwrap();
307 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
308 assert!(
309 !tui.frame()
310 .text()
311 .lines()
312 .take(18)
313 .any(|line| line.contains(command)),
314 "navigation leaked into transcript above the composer: {}",
315 tui.diagnostics()
316 );
317 }
318 tui.send(keys::key::esc()).unwrap();
319 tui.send(keys::key::esc()).unwrap();
320 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
321 assert!(
322 !tui.frame().contains("Backtrack preview"),
323 "view navigation became a rewindable turn: {}",
324 tui.diagnostics()
325 );
326 tui.shutdown();
327 }
328
329 #[test]
330 fn raw_slash_input_keeps_a_steady_submit_cue_and_runs_on_enter_without_another_key() {
331 // #6397: the `[↵]` chip follows the draft, not the paste-burst window, so
332 // it is already lit while a raw (non-bracketed) burst's Enter-suppression
333 // window is still open. It is therefore not a signal that Enter will
334 // submit; wait out the window (120ms) with a quiet PTY before pressing
335 // Enter, which must then run the command with no other key.
336 let (_workspace, mut tui) = start_with_titles(24, 80, false, &[]);
337 tui.send("/mcp").unwrap();
338 wait(&mut tui, "enter:run");
339 wait(&mut tui, "[↵]");
340 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
341 assert!(
342 tui.frame().contains("[↵]") && !tui.frame().contains("[·]"),
343 "submit cue did not stay steady: {}",
344 tui.diagnostics()
345 );
346 tui.send(keys::key::enter()).unwrap();
347 wait(&mut tui, "Extensions");
348 tui.shutdown();
349 }
350
351 #[test]
352 fn launch_recent_click_then_enter_resumes_without_another_mouse_event() {
353 for (rows, cols) in SIZES {
354 let (_workspace, mut tui) = start(rows, cols, false);
355 wait(&mut tui, TITLE);
356 capture(&mut tui, "home");
357 tui.send(keys::key::down()).unwrap();
358 tui.send(keys::key::down()).unwrap();
359 capture(&mut tui, "selected");
360 click_text(&mut tui, TITLE);
361 wait(&mut tui, "Resume");
362 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
363 capture(&mut tui, "confirm");
364 tui.send(keys::key::enter()).unwrap();
365 // No pointer motion follows Enter: the accepted action must run now.
366 wait(&mut tui, SAVED_TEXT);
367 if cols >= 80 {
368 wait(&mut tui, "Resumed:");
369 }
370 assert!(
371 !tui.frame().contains("Session loaded from"),
372 "resume should not add a technical path receipt to the conversation"
373 );
374 capture(&mut tui, "conversation");
375 tui.shutdown();
376 }
377 }
378
379 #[test]
380 fn launch_mcp_summary_opens_manager_by_click_and_keyboard() {
381 for (rows, cols) in SIZES {
382 let (_workspace, mut tui) = start(rows, cols, true);
383 wait(&mut tui, "MCP");
384 capture(&mut tui, "home-mcp");
385 click_text(&mut tui, "MCP");
386 wait(&mut tui, "Extensions");
387 wait(&mut tui, "launch-proof");
388 capture(&mut tui, "mcp");
389 tui.send(keys::key::esc()).unwrap();
390 wait(&mut tui, "New session");
391 // New session, the recent row (or compact See all), then MCP.
392 for _ in 0..3 {
393 tui.send(keys::key::down()).unwrap();
394 }
395 tui.send(keys::key::enter()).unwrap();
396 wait(&mut tui, "Extensions");
397 wait(&mut tui, "launch-proof");
398 tui.shutdown();
399 }
400 }
401
402 #[test]
403 fn launch_resume_buttons_support_mouse_cancel_and_keyboard_choice() {
404 for (rows, cols) in SIZES {
405 let (_workspace, mut tui) = start(rows, cols, false);
406 click_text(&mut tui, TITLE);
407 wait(&mut tui, "resume");
408 click_text(&mut tui, "cancel");
409 wait(&mut tui, "New session");
410 assert!(!tui.frame().contains(SAVED_TEXT));
411 click_text(&mut tui, TITLE);
412 wait(&mut tui, "resume");
413 tui.send(keys::key::tab()).unwrap();
414 tui.send(keys::key::enter()).unwrap();
415 wait(&mut tui, "New session");
416 assert!(!tui.frame().contains(SAVED_TEXT));
417 click_text(&mut tui, TITLE);
418 wait(&mut tui, "resume");
419 click_text(&mut tui, "resume");
420 wait(&mut tui, SAVED_TEXT);
421 tui.shutdown();
422 }
423 }
424
425 /// Optional review evidence from the real PTY, keeping cell colors rather
426 /// than relying on symbol-only goldens. The viewer supplies terminal fonts.
427 pub(super) fn capture(tui: &mut Harness, name: &str) {
428 let Some(directory) = std::env::var_os("QA_LAUNCH_CAPTURE_DIR") else {
429 return;
430 };
431 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
432 let frame = tui.frame();
433 let directory = std::path::PathBuf::from(directory);
434 std::fs::create_dir_all(&directory).unwrap();
435 let path = directory.join(format!("{name}-{}x{}.json", frame.cols(), frame.rows()));
436 std::fs::write(
437 path,
438 serde_json::to_vec_pretty(&frame.capture_cells()).unwrap(),
439 )
440 .unwrap();
441 }
442
443 #[test]
444 #[ignore = "opt-in visual evidence; writes only with QA_LAUNCH_CAPTURE_DIR"]
445 fn workbench_settings_visual_evidence() {
446 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
447 for (command, title, name) in [
448 ("/model", "route ·", "models"),
449 ("/provider", "Provider", "providers"),
450 ("/fleet", "Coordinator", "fleet"),
451 ("/plugin", "Extensions", "plugins"),
452 ("/config", "Settings", "settings"),
453 ("/statusline", "Status", "statusline"),
454 ] {
455 for (rows, cols) in SIZES {
456 let (_workspace, mut tui) = start(rows, cols, true);
457 tui.paste(command).unwrap();
458 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
459 tui.send(keys::key::enter()).unwrap();
460 wait(&mut tui, title);
461 capture(&mut tui, name);
462 if name == "providers" {
463 tui.send(keys::key::alt('v')).unwrap();
464 wait(&mut tui, "DeepSeek · Open details");
465 capture(&mut tui, "provider-details");
466 tui.send(keys::key::esc()).unwrap();
467 wait(&mut tui, "Provider");
468 }
469 tui.shutdown();
470 }
471 }
472 }
473
474 #[test]
475 #[ignore = "opt-in populated launch evidence; fixture sessions, no provider calls"]
476 fn workbench_populated_home_visual_evidence() {
477 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
478 for (rows, cols) in SIZES {
479 let (_workspace, mut tui) = start_with_titles(
480 rows,
481 cols,
482 true,
483 &[
484 "Polish the release notes",
485 "Investigate a provider timeout",
486 "Review the plugin setup flow",
487 ],
488 );
489 capture(&mut tui, "home-populated");
490 tui.send(keys::key::down()).unwrap();
491 tui.send(keys::key::down()).unwrap();
492 capture(&mut tui, "home-populated-selected");
493 tui.shutdown();
494 }
495 }
496
497 #[test]
498 fn launch_long_resume_title_preserves_warning_and_truthful_enter_hint() {
499 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";
500 for (rows, cols) in SIZES {
501 let (_workspace, mut tui) = start_titled(rows, cols, false, title);
502 click_text(&mut tui, "Investigate provider");
503 wait(&mut tui, "resume");
504 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
505 capture(&mut tui, "confirm-long");
506 let text = tui
507 .frame()
508 .text()
509 .chars()
510 .map(|ch| {
511 if ('\u{2500}'..='\u{257f}').contains(&ch) {
512 ' '
513 } else {
514 ch
515 }
516 })
517 .collect::<String>()
518 .split_whitespace()
519 .collect::<Vec<_>>()
520 .join(" ");
521 assert!(
522 text.contains("This replaces the current context with that session's history."),
523 "{text}"
524 );
525 tui.send(keys::key::tab()).unwrap();
526 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
527 let text = tui.frame().text();
528 assert!(text.contains("cancel Enter"), "{text}");
529 assert!(!text.contains("resume Enter"), "{text}");
530 capture(&mut tui, "confirm-cancel");
531 tui.send(keys::key::enter()).unwrap();
532 wait(&mut tui, "New session");
533 assert!(!tui.frame().contains(SAVED_TEXT));
534 tui.shutdown();
535 }
536 }
537
538 #[test]
539 #[ignore = "opt-in all-theme evidence; fixture sessions, no provider calls"]
540 fn workbench_every_theme_visual_evidence() {
541 assert!(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").is_some());
542 for theme in codewhale_palette::SELECTABLE_THEMES {
543 let (_workspace, mut tui) = start_with_theme(24, 80, true, &[TITLE], Some(theme.name()));
544 capture(&mut tui, &format!("theme-{}-home", theme.name()));
545 tui.paste("/statusline").unwrap();
546 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
547 tui.send(keys::key::enter()).unwrap();
548 wait(&mut tui, "Status");
549 capture(&mut tui, &format!("theme-{}-statusline", theme.name()));
550 tui.shutdown();
551 }
552 }
553
554 #[test]
555 #[ignore = "opt-in real launch animation capture; fixture state, no provider calls"]
556 fn workbench_whale_reveal_visual_evidence() {
557 let directory = std::path::PathBuf::from(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").unwrap());
558 std::fs::create_dir_all(&directory).unwrap();
559 let (_workspace, mut tui) =
560 start_with_options(32, 100, false, &[TITLE], Some("shoreline"), true, false);
561 let start = std::time::Instant::now();
562 for index in 0..16 {
563 let frame = tui.frame();
564 assert!(
565 frame.text().contains("New session"),
566 "controls must remain usable during reveal"
567 );
568 std::fs::write(
569 directory.join(format!("reveal-{index:02}-100x32.json")),
570 serde_json::to_vec_pretty(&frame.capture_cells()).unwrap(),
571 )
572 .unwrap();
573 std::thread::sleep(Duration::from_millis(50));
574 }
575 eprintln!("Captured launch reveal over {:?}", start.elapsed());
576 tui.shutdown();
577 }
578
579 /// Record temporal evidence from the real terminal, including its idle settle.
580 #[test]
581 #[ignore = "opt-in Underwater motion capture; isolated fixture, no provider calls"]
582 fn underwater_motion_visual_evidence() {
583 use std::io::Write;
584 let directory = std::path::PathBuf::from(std::env::var_os("QA_LAUNCH_CAPTURE_DIR").unwrap());
585 std::fs::create_dir_all(&directory).unwrap();
586 for (rows, cols) in [(24, 80), (36, 120)] {
587 let (_workspace, mut tui) =
588 start_with_options(rows, cols, false, &[TITLE], Some("underwater"), true, false);
589 // Home intentionally gives its brief whale reveal the stage. Sea life
590 // lives in the conversation field, so enter a fresh offline session.
591 tui.send(keys::key::ctrl('u')).unwrap();
592 click_text(&mut tui, "New session");
593 wait(&mut tui, "What do you want to accomplish?");
594 let file =
595 std::fs::File::create(directory.join(format!("ocean-{cols}x{rows}.jsonl.gz"))).unwrap();
596 let mut output = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
597 let start = std::time::Instant::now();
598 for index in 0..360u64 {
599 let frame = tui.frame();
600 assert!(
601 frame.contains("Type a message")
602 && frame.contains("What do you want to accomplish?"),
603 "motion cannot displace the conversation or composer"
604 );
605 serde_json::to_writer(
606 &mut output,
607 &serde_json::json!({
608 "elapsed_ms": start.elapsed().as_millis(),
609 "frame": frame.capture_cells()
610 }),
611 )
612 .unwrap();
613 output.write_all(b"\n").unwrap();
614 let target = Duration::from_millis((index + 1) * 33);
615 if let Some(remaining) = target.checked_sub(start.elapsed()) {
616 std::thread::sleep(remaining);
617 }
618 }
619 output.finish().unwrap();
620 tui.shutdown();
621 }
622 }
623
624 /// Exercise the visible catalog controls and provider search through the
625 /// input decoder. This only browses fixture state; it never applies a route.
626 #[test]
627 fn settings_catalog_controls_and_provider_search_work_with_mouse_and_keyboard() {
628 for (rows, cols) in SIZES {
629 let (_workspace, mut tui) = start(rows, cols, false);
630 tui.paste("/provider").unwrap();
631 tui.send(keys::key::enter()).unwrap();
632 wait(&mut tui, "Provider");
633 click_text(&mut tui, "browse all");
634 wait(&mut tui, "configured");
635 tui.send("/Anthropic").unwrap();
636 wait(&mut tui, "search: Anthropic");
637 capture(&mut tui, "providers-search");
638 tui.send(keys::key::esc()).unwrap();
639 wait(&mut tui, "Provider");
640 capture(&mut tui, "providers-catalog");
641 tui.send(keys::key::esc()).unwrap();
642 // Let the standalone Escape decode before starting a bracketed paste.
643 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
644 tui.paste("/model").unwrap();
645 tui.send(keys::key::enter()).unwrap();
646 wait(&mut tui, "route ·");
647 click_text(&mut tui, "browse catalog");
648 wait(&mut tui, "catalog");
649 capture(&mut tui, "models-catalog");
650 tui.send(keys::key::esc()).unwrap();
651 tui.shutdown();
652 }
653 }
654
655 #[test]
656 fn fleet_roles_open_the_shared_model_picker_and_escape_returns_to_the_same_role() {
657 for (rows, cols) in SIZES {
658 let (_workspace, mut tui) = start(rows, cols, false);
659 tui.paste("/fleet").unwrap();
660 tui.send(keys::key::enter()).unwrap();
661 wait(&mut tui, "Coordinator");
662 capture(&mut tui, "fleet-assignments");
663 tui.send(keys::key::enter()).unwrap();
664 wait(&mut tui, "Model · Coordinator");
665 wait(&mut tui, "Current session");
666 capture(&mut tui, "fleet-coordinator-model");
667 // The roster footer ("saved teams") can stay visible behind the
668 // picker at wide sizes, so it does not prove Esc landed. Wait for the
669 // picker itself to close and the screen to settle before the next
670 // key: a key sent inside the Esc disambiguation window is read as
671 // Alt+key and the role never changes.
672 close_picker(&mut tui, "Model · Coordinator");
673 tui.send(keys::key::down()).unwrap();
674 tui.send(keys::key::enter()).unwrap();
675 wait(&mut tui, "Model · manager");
676 capture(&mut tui, "fleet-role-model");
677 tui.send("search-proof").unwrap();
678 wait(&mut tui, "search-proof");
679 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
680 tui.send(keys::key::esc()).unwrap();
681 tui.wait_for(|frame| !frame.contains("search-proof"), WAIT)
682 .unwrap();
683 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
684 close_picker(&mut tui, "Model · manager");
685 tui.send(keys::key::enter()).unwrap();
686 wait(&mut tui, "Model · manager");
687 // Following Coordinator is a selectable local choice even without credentials.
688 tui.send(keys::key::enter()).unwrap();
689 wait(&mut tui, "Personal");
690 capture(&mut tui, "fleet-role-destination");
691 tui.shutdown();
692 }
693 }
694
695 /// Esc out of a Fleet model picker and wait until the roster is back and
696 /// quiet, so the next key is never folded into the Esc sequence.
697 fn close_picker(tui: &mut Harness, title: &str) {
698 tui.send(keys::key::esc()).unwrap();
699 if let Err(error) = tui.wait_for(|frame| !frame.contains(title), WAIT) {
700 panic!(
701 "waiting for {title:?} to close: {error}\n{}",
702 tui.diagnostics()
703 );
704 }
705 wait(tui, "saved teams");
706 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
707 }
708
709 #[test]
710 fn no_color_keeps_home_navigation_and_submit_cues_without_color() {
711 let sgr = regex::Regex::new(r"\x1b\[([0-9;:]*)m").unwrap();
712 for (rows, cols) in SIZES {
713 let (_workspace, mut tui) =
714 start_with_options(rows, cols, false, &[TITLE], Some("shoreline"), false, true);
715 wait(&mut tui, "[·]");
716 tui.send(keys::key::down()).unwrap();
717 tui.send(keys::key::down()).unwrap();
718 capture(&mut tui, "no-color-selected");
719 tui.send(keys::key::enter()).unwrap();
720 wait(&mut tui, "Resume");
721 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
722 tui.send(keys::key::enter()).unwrap();
723 wait(&mut tui, SAVED_TEXT);
724 tui.send("monochrome draft").unwrap();
725 wait(&mut tui, "[↵]");
726 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
727 capture(&mut tui, "no-color-draft");
728
729 for row in 0..rows {
730 for col in 0..cols {
731 assert_eq!(
732 tui.frame().colors_at(row, col),
733 Some((
734 qa_harness::frame::Color::Default,
735 qa_harness::frame::Color::Default
736 )),
737 "{cols}x{rows} cell ({row}, {col}) added a color"
738 );
739 }
740 }
741 // Inspect the whole emitted stream, not just its last rendered frame.
742 let transcript = tui.transcript();
743 let output = String::from_utf8_lossy(&transcript);
744 for codes in sgr.captures_iter(&output) {
745 for code in codes[1]
746 .split([';', ':'])
747 .filter_map(|code| code.parse::<u16>().ok())
748 {
749 assert!(
750 !matches!(code, 30..=38 | 40..=48 | 58 | 90..=97 | 100..=107),
751 "{cols}x{rows} emitted color SGR {:?}",
752 &codes[0]
753 );
754 }
755 }
756 tui.shutdown();
757 }
758 }
759
760 #[test]
761 fn home_returns_to_the_same_conversation_by_escape_click_and_typing() {
762 for (rows, cols) in SIZES {
763 let (_workspace, mut tui) = start(rows, cols, false);
764 click_text(&mut tui, TITLE);
765 wait(&mut tui, "Resume");
766 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
767 tui.send(keys::key::enter()).unwrap();
768 wait(&mut tui, SAVED_TEXT);
769 for return_path in ["escape", "click", "type"] {
770 tui.paste("/home").unwrap();
771 tui.send(keys::key::enter()).unwrap();
772 wait(&mut tui, "Back to conversation");
773 capture(&mut tui, "home-return");
774 match return_path {
775 "escape" => tui.send(keys::key::esc()).unwrap(),
776 "click" => click_text(&mut tui, "Back to conversation"),
777 _ => tui.send("draft stays here").unwrap(),
778 }
779 tui.wait_for(|frame| !frame.contains("Back to conversation"), WAIT)
780 .unwrap();
781 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
782 if return_path == "type" {
783 wait(&mut tui, "draft stays here");
784 capture(&mut tui, "home-return-draft");
785 tui.send(keys::key::ctrl('u')).unwrap();
786 }
787 // Slash commands add transcript rows. At 40x12 the original
788 // message is now above the viewport, so inspect scrollback.
789 tui.send(keys::key::page_up()).unwrap();
790 wait(&mut tui, SAVED_TEXT);
791 tui.send(keys::key::alt('G')).unwrap();
792 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
793 }
794 tui.paste("/overview").unwrap();
795 tui.send(keys::key::enter()).unwrap();
796 tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
797 // The dashboard is longer than a short transcript viewport.
798 for _ in 0..20 {
799 if tui.frame().contains("Quick Actions") {
800 break;
801 }
802 tui.send(keys::key::page_up()).unwrap();
803 tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
804 }
805 wait(&mut tui, "Quick Actions");
806 tui.shutdown();
807 }
808 }
809
809 lines RUST