| 1 | //! Real-PTY regression for the active composer's painted `[↵]` submit |
| 2 | //! affordance (#5773, TUI-UX-01 active-work half). |
| 3 | //! |
| 4 | //! The session is driven through the real onboarding flow into deterministic |
| 5 | //! offline-explore mode, so bare submit resolves to Queue: no provider, no |
| 6 | //! credentials, no network. The user types a draft and clicks the painted |
| 7 | //! send cell with SGR mouse down/up. The click must reach the same queued |
| 8 | //! keyboard-submit path as Enter — proven by the offline queue receipt toast |
| 9 | //! and the durable `Queued #1:` pending-input preview, which renders directly |
| 10 | //! from `app.queued_messages` — rather than leaving the draft untouched as a |
| 11 | //! no-op click. |
| 12 | |
| 13 | #![cfg(all(unix, feature = "long-running-tests"))] |
| 14 | |
| 15 | use std::time::{Duration, Instant}; |
| 16 | |
| 17 | use super::qa_harness; |
| 18 | use qa_harness::Frame; |
| 19 | use qa_harness::harness::{Harness, make_sealed_workspace}; |
| 20 | use qa_harness::keys; |
| 21 | use qa_harness::modes::mode; |
| 22 | |
| 23 | /// (rows, cols): the release acceptance matrix for compact through wide terminals. |
| 24 | const SIZES: [(u16, u16); 5] = [(12, 40), (16, 60), (24, 80), (32, 100), (40, 140)]; |
| 25 | |
| 26 | const STARTUP_WAIT: Duration = Duration::from_secs(15); |
| 27 | const SETTLE_WAIT: Duration = Duration::from_secs(5); |
| 28 | |
| 29 | /// Offline-queue receipt toast (MessageId::ToastQueuedOffline, en). |
| 30 | const QUEUE_RECEIPT_TOAST: &str = "Saved for later. Connect a provider to send."; |
| 31 | /// Compact-width prefix of that toast: the footer truncates the tail when the |
| 32 | /// row cannot hold the full sentence next to the posture chips (deterministic |
| 33 | /// layout boundary, observed painted as "Saved for later." at 40 and 60 |
| 34 | /// cols). |
| 35 | const QUEUE_RECEIPT_TOAST_COMPACT: &str = "Saved for later."; |
| 36 | |
| 37 | /// The receipt needle this width can actually paint: full toast where the |
| 38 | /// footer row fits it, its unwrapped prefix on compact layouts. |
| 39 | fn queue_receipt_needle(cols: u16) -> &'static str { |
| 40 | if cols >= 100 { |
| 41 | QUEUE_RECEIPT_TOAST |
| 42 | } else { |
| 43 | QUEUE_RECEIPT_TOAST_COMPACT |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | #[test] |
| 48 | fn active_composer_pointer_submit_queues_without_provider() { |
| 49 | for (rows, cols) in SIZES { |
| 50 | run_pointer_submit_case(rows, cols); |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | fn run_pointer_submit_case(rows: u16, cols: u16) { |
| 55 | let size = format!("{cols}x{rows}"); |
| 56 | let workspace = make_sealed_workspace().expect("sealed workspace"); |
| 57 | // Seed only the two first-run markers: prior onboarding completed |
| 58 | // ($HOME/.codewhale/.onboarded) and this workspace is trusted |
| 59 | // (<workspace>/.deepseek/trusted). No provider, key, or route is seeded. |
| 60 | std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").expect("onboarded marker"); |
| 61 | let trust_dir = workspace.workspace().join(".deepseek"); |
| 62 | std::fs::create_dir_all(&trust_dir).expect("workspace trust dir"); |
| 63 | std::fs::write(trust_dir.join("trusted"), "").expect("workspace trust marker"); |
| 64 | |
| 65 | let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 66 | .cwd(workspace.workspace()) |
| 67 | .clear_env() |
| 68 | .seal_home(workspace.home()) |
| 69 | .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1") |
| 70 | .env("CODEWHALE_NO_UPDATE_CHECK", "1") |
| 71 | .env("NO_ANIMATIONS", "1") |
| 72 | .env("RUST_LOG", "warn") |
| 73 | .args([ |
| 74 | "--workspace", |
| 75 | workspace.workspace().to_str().expect("workspace UTF-8"), |
| 76 | "--no-project-config", |
| 77 | "--fresh", |
| 78 | "--mouse-capture", |
| 79 | ]) |
| 80 | .size(rows, cols) |
| 81 | .spawn() |
| 82 | .expect("start distributed TUI binary"); |
| 83 | |
| 84 | // Walk the real onboarding: provider choice → Explore Offline (Ctrl+O) |
| 85 | // → ready screen → Enter into Tideline Startup → New session. |
| 86 | wait_or_panic( |
| 87 | &mut tui, |
| 88 | "Choose your model provider", |
| 89 | STARTUP_WAIT, |
| 90 | &format!("{size}: onboarding provider choice"), |
| 91 | ); |
| 92 | tui.send(keys::key::ctrl('o')) |
| 93 | .expect("choose Explore Offline"); |
| 94 | wait_or_panic( |
| 95 | &mut tui, |
| 96 | "You're ready.", |
| 97 | SETTLE_WAIT, |
| 98 | &format!("{size}: offline explore ready"), |
| 99 | ); |
| 100 | tui.send(keys::key::enter()).expect("leave onboarding"); |
| 101 | // PTY reads can split a redraw: the launch header arrives before the |
| 102 | // composer, with onboarding rows still on screen (Buildkite #1861/#1867). |
| 103 | // Wait for the input surface as well as the header before asserting it. |
| 104 | tui.wait_for( |
| 105 | |frame| { |
| 106 | let text = frame.text(); |
| 107 | text.contains("New session") && text.contains('❯') && !text.contains("You're ready.") |
| 108 | }, |
| 109 | STARTUP_WAIT, |
| 110 | ) |
| 111 | .unwrap_or_else(|error| panic!("{size}: show the launch card and composer: {error}")); |
| 112 | tui.pump(); |
| 113 | assert_startup_contract(tui.frame(), rows, cols, &size); |
| 114 | // Typing goes straight to the composer; Enter sends the first message |
| 115 | // and the session begins (the card dissolved on the first keystroke). |
| 116 | // type_line, not send+enter: a zero-gap PTY write is paste-classified |
| 117 | // and the immediate Enter would be absorbed as a pasted newline. |
| 118 | tui.type_line("start the session") |
| 119 | .expect("type and send the first prompt"); |
| 120 | if tui |
| 121 | .wait_for(|frame| !frame.text().contains('\u{2442}'), STARTUP_WAIT) |
| 122 | .is_err() |
| 123 | { |
| 124 | panic!( |
| 125 | "{size}: Startup New session did not enter the live shell\n{}", |
| 126 | tui.diagnostics() |
| 127 | ); |
| 128 | } |
| 129 | tui.wait_for_idle(Duration::from_millis(300), SETTLE_WAIT) |
| 130 | .expect("session shell settles"); |
| 131 | // Prove the steady shell before clearing the onboarding seed. The clear |
| 132 | // action intentionally emits a transient `Ctrl+Z restores` receipt, and |
| 133 | // live receipts outrank the footer's key hint while present. |
| 134 | tui.pump(); |
| 135 | assert_live_shell_contract(tui.frame(), cols, &size); |
| 136 | |
| 137 | // Clear the onboarding seed so the unique pointer-test draft starts from |
| 138 | // an empty composer. |
| 139 | tui.send(keys::key::ctrl('u')).expect("clear seeded input"); |
| 140 | tui.wait_for_idle(Duration::from_millis(200), SETTLE_WAIT) |
| 141 | .expect("composer cleared"); |
| 142 | |
| 143 | // Mouse capture must be live before the click, or the SGR bytes would |
| 144 | // test nothing. Readiness comes from the control stream, not the frame. |
| 145 | wait_for_mouse_capture(&mut tui, &size); |
| 146 | |
| 147 | // Baseline the queue depth while the composer is empty: the pending |
| 148 | // preview row paints only then, and the later proof needs the exact |
| 149 | // pre-click depth. |
| 150 | let queued_before = queued_count(&normalized_text(tui.frame())); |
| 151 | |
| 152 | // Type a unique draft and let the composer settle before recording |
| 153 | // coordinates, so no late layout shift moves the cells under us. |
| 154 | let draft = format!("qa pointer draft {size}"); |
| 155 | tui.send(keys::key::text(&draft)).expect("type draft"); |
| 156 | wait_or_panic( |
| 157 | &mut tui, |
| 158 | &draft, |
| 159 | SETTLE_WAIT, |
| 160 | &format!("{size}: draft echo"), |
| 161 | ); |
| 162 | tui.wait_for_idle(Duration::from_millis(200), SETTLE_WAIT) |
| 163 | .expect("composer settles with draft"); |
| 164 | |
| 165 | // The paste-burst window treats input arriving within ~150ms of the |
| 166 | // keystrokes as paste; stay clearly outside it before clicking. |
| 167 | std::thread::sleep(Duration::from_millis(200)); |
| 168 | |
| 169 | tui.pump(); |
| 170 | let (send_row, send_col) = tui.frame().find_text("[↵]").unwrap_or_else(|| { |
| 171 | panic!( |
| 172 | "{size}: [↵] submit affordance not painted\n{}", |
| 173 | tui.diagnostics() |
| 174 | ) |
| 175 | }); |
| 176 | // Baseline: no queue receipt toast exists before the click, so the |
| 177 | // receipt below can only be produced by this gesture. (The onboarding |
| 178 | // seed may already sit in the offline queue; the unique draft cannot.) |
| 179 | let receipt = queue_receipt_needle(cols); |
| 180 | tui.pump(); |
| 181 | let before = normalized_text(tui.frame()); |
| 182 | assert!( |
| 183 | !before.contains(receipt), |
| 184 | "{size}: queue receipt already visible before any submit\n{}", |
| 185 | tui.diagnostics() |
| 186 | ); |
| 187 | |
| 188 | // Click the middle cell of the three-cell `[↵]` affordance: SGR down, |
| 189 | // settle, SGR up — the sequence a real terminal sends for one click. |
| 190 | tui.send(keys::mouse::down(send_row, send_col + 1)) |
| 191 | .expect("SGR mouse down on [↵]"); |
| 192 | tui.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2)) |
| 193 | .expect("down settles"); |
| 194 | tui.send(keys::mouse::up(send_row, send_col + 1)) |
| 195 | .expect("SGR mouse up on [↵]"); |
| 196 | |
| 197 | // Distinguishing assertion: a real submit — the pointer click or keyboard |
| 198 | // Enter alike — consumes the draft into the deterministic offline queue |
| 199 | // and paints the queue receipt toast. A no-op click paints no receipt |
| 200 | // and never grows the queue. The receipt toast is transient, so either |
| 201 | // signal (toast seen, or the queue count grew) proves the dispatch. |
| 202 | let expected = queued_before.map(|n| n + 1); |
| 203 | let deadline = Instant::now() + qa_harness::harness::ci_scaled(SETTLE_WAIT); |
| 204 | let retry_at = Instant::now() + qa_harness::harness::ci_scaled(SETTLE_WAIT / 2); |
| 205 | let mut retried = false; |
| 206 | println!( |
| 207 | "POINTER DEBUG {size}: queued_before={queued_before:?} expected={expected:?} receipt={receipt:?}" |
| 208 | ); |
| 209 | loop { |
| 210 | tui.pump(); |
| 211 | let text = normalized_text(tui.frame()); |
| 212 | let seen = queued_count(&text); |
| 213 | // The click proves itself by either the receipt toast or the queue |
| 214 | // preview appearing (baseline absent -> something queued) or growing |
| 215 | // by exactly this draft (baseline visible -> baseline + 1). |
| 216 | let grew = match (queued_before, seen) { |
| 217 | (Some(before), Some(now)) => now == before + 1, |
| 218 | (None, Some(_)) => true, |
| 219 | _ => false, |
| 220 | }; |
| 221 | if text.contains(receipt) || grew { |
| 222 | println!( |
| 223 | "POINTER DEBUG {size}: broke with seen={seen:?} expected={expected:?} receipt_seen={}", |
| 224 | text.contains(receipt) |
| 225 | ); |
| 226 | break; |
| 227 | } |
| 228 | // One bounded retry at the half-way point: re-find the affordance |
| 229 | // (a redraw may have shifted cells between find and click under |
| 230 | // runner load) and click it again. Keep polling after it — the app |
| 231 | // may take a beat to process the second gesture. |
| 232 | if !retried && Instant::now() >= retry_at { |
| 233 | retried = true; |
| 234 | let (retry_row, retry_col) = tui.frame().find_text("[↵]").unwrap_or_else(|| { |
| 235 | panic!( |
| 236 | "{size}: [↵] submit affordance not painted on retry\n{}", |
| 237 | tui.diagnostics() |
| 238 | ) |
| 239 | }); |
| 240 | tui.send(keys::mouse::down(retry_row, retry_col + 1)) |
| 241 | .expect("SGR mouse down on [↵] retry"); |
| 242 | std::thread::sleep(Duration::from_millis(150)); |
| 243 | tui.send(keys::mouse::up(retry_row, retry_col + 1)) |
| 244 | .expect("SGR mouse up on [↵] retry"); |
| 245 | } |
| 246 | if Instant::now() >= deadline { |
| 247 | panic!( |
| 248 | "{size}: click on [↵] at ({send_row},{}) produced no queue receipt \ |
| 249 | {receipt:?} and no queue growth — pointer submit did not reach the \ |
| 250 | keyboard-submit dispatch path (seen={seen:?})\n{}", |
| 251 | send_col + 1, |
| 252 | tui.diagnostics() |
| 253 | ); |
| 254 | } |
| 255 | std::thread::sleep(Duration::from_millis(100)); |
| 256 | } |
| 257 | |
| 258 | // Durable queue proof at every size: the pending-input preview renders |
| 259 | // straight from `app.queued_messages` — the real queue state, no |
| 260 | // slash-menu navigation needed. The queue must still report at least |
| 261 | // the entry this gesture added (the 40-column floor truncates the |
| 262 | // preview before the draft text, so the entry count, not the text, is |
| 263 | // the signal at the floor). |
| 264 | tui.pump(); |
| 265 | let after = normalized_text(tui.frame()); |
| 266 | let grew = match (queued_count(&after), queued_before) { |
| 267 | (Some(after_n), Some(before_n)) => after_n == before_n + 1, |
| 268 | _ => after.contains("Queued "), |
| 269 | }; |
| 270 | assert!( |
| 271 | grew, |
| 272 | "{size}: the queue did not grow by exactly the pointer-submitted draft\n{}", |
| 273 | tui.diagnostics() |
| 274 | ); |
| 275 | |
| 276 | let modes = tui.terminal_modes(); |
| 277 | assert_eq!( |
| 278 | modes.state(mode::MOUSE_SGR), |
| 279 | Some(true), |
| 280 | "{size}: SGR mouse encoding must be enabled for the click to be meaningful\n{}", |
| 281 | modes.debug_dump() |
| 282 | ); |
| 283 | assert!( |
| 284 | modes.was_ever_enabled(mode::MOUSE_BUTTON), |
| 285 | "{size}: mouse button tracking was never enabled\n{}", |
| 286 | modes.debug_dump() |
| 287 | ); |
| 288 | |
| 289 | let _ = tui.shutdown(); |
| 290 | } |
| 291 | |
| 292 | /// Frame text with rows joined on single spaces, so assertions survive a |
| 293 | /// narrow-terminal wrap of the needle across two painted rows. |
| 294 | fn normalized_text(frame: &Frame) -> String { |
| 295 | frame |
| 296 | .text() |
| 297 | .lines() |
| 298 | .map(str::trim) |
| 299 | .filter(|line| !line.is_empty()) |
| 300 | .collect::<Vec<_>>() |
| 301 | .join(" ") |
| 302 | } |
| 303 | |
| 304 | fn assert_startup_contract(frame: &Frame, rows: u16, cols: u16, size: &str) { |
| 305 | let text = frame.text(); |
| 306 | // The launch card's own truth: the wordmark + version, the prominent |
| 307 | // new-session entry, and the focused composer. The posture bar and |
| 308 | // metrics line appear only once a session exists, so `context` is NOT |
| 309 | // asserted here any more (SHELL-DESIGN-20260901 Round 5). |
| 310 | for needle in ["codewhale", "❯"] { |
| 311 | assert!( |
| 312 | text.contains(needle), |
| 313 | "{size}: startup misses {needle:?}\n{}", |
| 314 | frame.debug_dump() |
| 315 | ); |
| 316 | } |
| 317 | // Empty workspaces keep the invitation without an empty history section. |
| 318 | assert!( |
| 319 | text.contains("New session"), |
| 320 | "{size}: startup misses invitation\n{}", |
| 321 | frame.debug_dump() |
| 322 | ); |
| 323 | assert!( |
| 324 | !text.contains("No recent sessions"), |
| 325 | "{size}: empty history adds noise\n{}", |
| 326 | frame.debug_dump() |
| 327 | ); |
| 328 | for retired_mark_row in ["▄▄▄▄██▌", "▜████▀▘"] { |
| 329 | assert!( |
| 330 | !text.contains(retired_mark_row), |
| 331 | "{size}: startup still paints the retired approximate mark\n{}", |
| 332 | frame.debug_dump() |
| 333 | ); |
| 334 | } |
| 335 | assert_eq!(frame.rows(), rows, "{size}: PTY row count drift"); |
| 336 | assert_eq!(frame.cols(), cols, "{size}: PTY column count drift"); |
| 337 | assert!( |
| 338 | frame.max_row_width() <= usize::from(cols), |
| 339 | "{size}: startup row overflow\n{}", |
| 340 | frame.debug_dump() |
| 341 | ); |
| 342 | } |
| 343 | |
| 344 | fn assert_live_shell_contract(frame: &Frame, cols: u16, size: &str) { |
| 345 | let text = frame.text(); |
| 346 | // The bottom metrics row owns the model; repository state belongs to |
| 347 | // the launch header and git view. This sealed offline session uses the |
| 348 | // default model, which must remain visible even at 40 columns. |
| 349 | let metrics = frame.row(frame.rows().saturating_sub(1)); |
| 350 | assert!( |
| 351 | metrics.contains("deepseek-flash"), |
| 352 | "{size}: live shell misses the model in the metrics line\n{}", |
| 353 | frame.debug_dump() |
| 354 | ); |
| 355 | // Compact metrics omit the passive help hint; F1 still opens Help. |
| 356 | assert!( |
| 357 | !text.contains("RUNS") || cols < 100, |
| 358 | "{size}: passive duplicate Tideline rail is still visible\n{}", |
| 359 | frame.debug_dump() |
| 360 | ); |
| 361 | assert!( |
| 362 | frame.max_row_width() <= usize::from(cols), |
| 363 | "{size}: live-shell row overflow\n{}", |
| 364 | frame.debug_dump() |
| 365 | ); |
| 366 | } |
| 367 | |
| 368 | /// The queue depth the frame's pending-input preview reports |
| 369 | /// (`Queued N · next: …`), when one is painted. |
| 370 | fn queued_count(text: &str) -> Option<u32> { |
| 371 | let idx = text.find("Queued ")?; |
| 372 | let rest = &text[idx + "Queued ".len()..]; |
| 373 | let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); |
| 374 | digits.parse().ok() |
| 375 | } |
| 376 | |
| 377 | fn wait_or_panic(tui: &mut Harness, needle: &str, timeout: Duration, label: &str) { |
| 378 | if tui.wait_for_text(needle, timeout).is_err() { |
| 379 | panic!("{label}: {needle:?} not visible\n{}", tui.diagnostics()); |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | fn wait_for_mouse_capture(tui: &mut Harness, size: &str) { |
| 384 | let deadline = Instant::now() + qa_harness::harness::ci_scaled(STARTUP_WAIT); |
| 385 | loop { |
| 386 | tui.pump(); |
| 387 | let modes = tui.terminal_modes(); |
| 388 | if modes.state(mode::MOUSE_SGR) == Some(true) |
| 389 | && modes.state(mode::MOUSE_BUTTON) == Some(true) |
| 390 | { |
| 391 | return; |
| 392 | } |
| 393 | if Instant::now() >= deadline { |
| 394 | panic!( |
| 395 | "{size}: mouse capture never enabled in the control stream\n{}", |
| 396 | tui.diagnostics() |
| 397 | ); |
| 398 | } |
| 399 | std::thread::sleep(Duration::from_millis(40)); |
| 400 | } |
| 401 | } |
| 402 |