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