anvilsign in

collin/browser-terminal-extension

1//! End-to-end: browser-shaped client -> WebSocket -> pty -> shell -> back.
2//!
3//! Uses `sh` rather than tmux so the test doesn't depend on a tmux server or
4//! leave sessions behind. The pty path is identical either way.
5
6use std::time::Duration;
7
8use futures_util::{SinkExt, StreamExt};
9use tokio_tungstenite::tungstenite::Message;
10use tokio_tungstenite::tungstenite::client::IntoClientRequest;
11
12use termbridge::pty::Profile;
13use termbridge::{Config, Server};
14
15const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
16const ORIGIN: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
17
18fn sh_profile() -> Profile {
19 Profile {
20 program: "/bin/sh".into(),
21 args: vec![],
22 }
23}
24
25async fn connect_authed(
26 server: &Server,
27) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
28 let mut req = server.url().into_client_request().unwrap();
29 req.headers_mut().insert("origin", ORIGIN.parse().unwrap());
30 let (mut ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
31
32 ws.send(Message::Text(
33 serde_json::json!({"type": "auth", "token": TOKEN})
34 .to_string()
35 .into(),
36 ))
37 .await
38 .unwrap();
39 let ok = ws.next().await.unwrap().unwrap();
40 assert!(ok.to_text().unwrap().contains("\"ok\""), "{ok:?}");
41 ws
42}
43
44/// Read binary frames until `needle` shows up in the accumulated output.
45async fn wait_for(
46 ws: &mut tokio_tungstenite::WebSocketStream<
47 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
48 >,
49 needle: &str,
50) -> String {
51 let mut acc = Vec::new();
52 let found = tokio::time::timeout(Duration::from_secs(10), async {
53 while let Some(Ok(msg)) = ws.next().await {
54 if let Message::Binary(b) = msg {
55 acc.extend_from_slice(&b);
56 if String::from_utf8_lossy(&acc).contains(needle) {
57 return true;
58 }
59 }
60 }
61 false
62 })
63 .await
64 .unwrap_or(false);
65
66 let text = String::from_utf8_lossy(&acc).to_string();
67 assert!(found, "never saw {needle:?}; got:\n{}", printable(&text));
68 text
69}
70
71/// Escape control bytes so a failing assertion can't repaint the terminal that
72/// is printing it.
73fn printable(s: &str) -> String {
74 s.chars()
75 .map(|c| match c {
76 '\n' | '\t' => c.to_string(),
77 c if (c as u32) < 0x20 || c as u32 == 0x7f => format!("\\x{:02x}", c as u32),
78 c => c.to_string(),
79 })
80 .collect()
81}
82
83/// Wait until the shell inside tmux is actually consuming input.
84///
85/// tmux drops keystrokes that arrive before its client has finished attaching,
86/// so anything typed immediately after `open` can vanish. Probe until the echo
87/// comes back rather than assuming a fixed delay is enough.
88async fn wait_until_ready(
89 ws: &mut tokio_tungstenite::WebSocketStream<
90 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
91 >,
92 probe: &str,
93) {
94 let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
95 let mut acc = Vec::new();
96 loop {
97 assert!(
98 tokio::time::Instant::now() < deadline,
99 "shell never became ready; saw:\n{}",
100 printable(&String::from_utf8_lossy(&acc))
101 );
102 ws.send(Message::Binary(
103 format!("echo {probe}\n").into_bytes().into(),
104 ))
105 .await
106 .unwrap();
107
108 let until = tokio::time::Instant::now() + Duration::from_millis(700);
109 loop {
110 match tokio::time::timeout_at(until, ws.next()).await {
111 Ok(Some(Ok(Message::Binary(b)))) => {
112 acc.extend_from_slice(&b);
113 // Twice: once as terminal echo of what we typed, once as
114 // the command's own output. One occurrence only proves the
115 // characters were displayed, not that a shell ran them.
116 if String::from_utf8_lossy(&acc).matches(probe).count() >= 2 {
117 return;
118 }
119 }
120 Ok(Some(Ok(_))) => {}
121 Ok(Some(Err(e))) => panic!("connection error while waiting: {e}"),
122 Ok(None) => panic!("connection closed while waiting for the shell"),
123 Err(_) => break, // no progress this round; probe again
124 }
125 }
126 }
127}
128
129async fn start(profile: Profile) -> Server {
130 let mut cfg = Config::new(TOKEN, vec![ORIGIN.to_string()]);
131 cfg.profile = profile;
132 Server::start(cfg, 0).await.unwrap()
133}
134
135#[tokio::test]
136async fn shell_runs_and_output_comes_back_as_binary() {
137 let server = start(sh_profile()).await;
138 let mut ws = connect_authed(&server).await;
139
140 ws.send(Message::Text(
141 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
142 .to_string()
143 .into(),
144 ))
145 .await
146 .unwrap();
147
148 // Keystrokes go as binary, exactly as the sidebar will send them.
149 ws.send(Message::Binary(b"echo hello-from-pty\n".to_vec().into()))
150 .await
151 .unwrap();
152
153 wait_for(&mut ws, "hello-from-pty").await;
154}
155
156/// The pty must report the size we asked for — this is what makes tmux and vim
157/// lay out correctly in a narrow sidebar.
158#[tokio::test]
159async fn winsize_is_applied_and_resizable() {
160 let server = start(sh_profile()).await;
161 let mut ws = connect_authed(&server).await;
162
163 ws.send(Message::Text(
164 serde_json::json!({"type": "open", "cols": 40, "rows": 20})
165 .to_string()
166 .into(),
167 ))
168 .await
169 .unwrap();
170 ws.send(Message::Binary(b"stty size\n".to_vec().into()))
171 .await
172 .unwrap();
173 wait_for(&mut ws, "20 40").await;
174
175 // Now resize, as the sidebar does when the user drags its edge.
176 ws.send(Message::Text(
177 serde_json::json!({"type": "resize", "cols": 100, "rows": 30})
178 .to_string()
179 .into(),
180 ))
181 .await
182 .unwrap();
183 tokio::time::sleep(Duration::from_millis(200)).await;
184 ws.send(Message::Binary(b"stty size\n".to_vec().into()))
185 .await
186 .unwrap();
187 wait_for(&mut ws, "30 100").await;
188}
189
190/// The pty is a byte pipe. Anything that isn't valid UTF-8 must survive, which
191/// is the property a JSON transport could not have given us.
192#[tokio::test]
193async fn non_utf8_output_survives_intact() {
194 let server = start(sh_profile()).await;
195 let mut ws = connect_authed(&server).await;
196 ws.send(Message::Text(
197 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
198 .to_string()
199 .into(),
200 ))
201 .await
202 .unwrap();
203
204 // 0xff is not valid UTF-8 anywhere. Bracket it so we can find it.
205 ws.send(Message::Binary(
206 b"printf 'S\\377\\376E\\n'\n".to_vec().into(),
207 ))
208 .await
209 .unwrap();
210
211 let mut acc = Vec::new();
212 let found = tokio::time::timeout(Duration::from_secs(10), async {
213 while let Some(Ok(Message::Binary(b))) = ws.next().await {
214 acc.extend_from_slice(&b);
215 if acc.windows(4).any(|w| w == [b'S', 0xff, 0xfe, b'E']) {
216 return true;
217 }
218 }
219 false
220 })
221 .await
222 .unwrap_or(false);
223
224 assert!(
225 found,
226 "raw bytes were mangled in transit; got {:x?}",
227 &acc[..acc.len().min(400)]
228 );
229}
230
231/// Exiting the shell must be reported, not silently hang the sidebar.
232#[tokio::test]
233async fn shell_exit_is_reported() {
234 let server = start(sh_profile()).await;
235 let mut ws = connect_authed(&server).await;
236 ws.send(Message::Text(
237 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
238 .to_string()
239 .into(),
240 ))
241 .await
242 .unwrap();
243 ws.send(Message::Binary(b"exit 7\n".to_vec().into()))
244 .await
245 .unwrap();
246
247 let got = tokio::time::timeout(Duration::from_secs(10), async {
248 while let Some(Ok(msg)) = ws.next().await {
249 if let Message::Text(t) = msg {
250 if t.contains("\"exit\"") {
251 return Some(t.to_string());
252 }
253 }
254 }
255 None
256 })
257 .await
258 .unwrap_or(None);
259
260 assert!(got.is_some(), "expected an exit message");
261}
262
263/// The client cannot choose what runs. Even authenticated, the protocol has no
264/// field for argv — an auth bypass gets you the configured profile, not `exec`.
265#[tokio::test]
266async fn client_cannot_choose_the_program() {
267 let server = start(sh_profile()).await;
268 let mut ws = connect_authed(&server).await;
269
270 // Every field an attacker might hope is honoured.
271 ws.send(Message::Text(
272 serde_json::json!({
273 "type": "open", "cols": 80, "rows": 24,
274 "cmd": ["/bin/sh", "-c", "echo PWNED"],
275 "command": "echo PWNED",
276 "program": "/usr/bin/id",
277 "args": ["-a"],
278 "env": {"LD_PRELOAD": "/tmp/evil.so"}
279 })
280 .to_string()
281 .into(),
282 ))
283 .await
284 .unwrap();
285
286 // Prove the session is the configured shell and that nothing else ran.
287 ws.send(Message::Binary(b"echo marker-$((6*7))\n".to_vec().into()))
288 .await
289 .unwrap();
290 let out = wait_for(&mut ws, "marker-42").await;
291 assert!(
292 !out.contains("PWNED"),
293 "client-supplied command was executed:\n{out}"
294 );
295}
296
297/// The whole reason tmux is the persistence layer: state must survive the
298/// sidebar closing. Disconnect, reconnect, and the shell is still there.
299#[tokio::test]
300async fn tmux_session_survives_disconnect() {
301 if !Profile::tmux_available() {
302 eprintln!("skipping: tmux not installed");
303 return;
304 }
305 // Private socket + session so we never touch the user's real tmux.
306 let sock = "termbridge-test";
307 let sess = "termbridge-e2e";
308 let tmux = |args: &[&str]| {
309 std::process::Command::new("tmux")
310 .args(["-L", sock])
311 .args(args)
312 .output()
313 };
314 let _ = tmux(&["kill-server"]);
315
316 let profile = Profile {
317 program: "tmux".into(),
318 // Force /bin/sh: tmux's default-shell may be fish, whose assignment
319 // syntax differs. The test is about persistence, not shell dialects.
320 args: ["-L", sock, "new-session", "-A", "-s", sess, "/bin/sh"]
321 .iter()
322 .map(|s| s.to_string())
323 .collect(),
324 };
325
326 let server = start(profile).await;
327
328 // First attach: leave a marker in the shell's state.
329 {
330 let mut ws = connect_authed(&server).await;
331 ws.send(Message::Text(
332 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
333 .to_string()
334 .into(),
335 ))
336 .await
337 .unwrap();
338 wait_until_ready(&mut ws, "boot-one").await;
339 ws.send(Message::Binary(
340 b"MARKER=survived-the-disconnect\n".to_vec().into(),
341 ))
342 .await
343 .unwrap();
344 ws.send(Message::Binary(b"echo first-attach-ok\n".to_vec().into()))
345 .await
346 .unwrap();
347 wait_for(&mut ws, "first-attach-ok").await;
348 ws.close(None).await.unwrap();
349 } // socket dropped == sidebar closed
350
351 tokio::time::sleep(Duration::from_millis(500)).await;
352
353 // Second attach: same session, shell variable still set.
354 {
355 let mut ws = connect_authed(&server).await;
356 ws.send(Message::Text(
357 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
358 .to_string()
359 .into(),
360 ))
361 .await
362 .unwrap();
363 wait_until_ready(&mut ws, "boot-two").await;
364 ws.send(Message::Binary(b"echo \"[$MARKER]\"\n".to_vec().into()))
365 .await
366 .unwrap();
367 wait_for(&mut ws, "[survived-the-disconnect]").await;
368 ws.close(None).await.unwrap();
369 }
370
371 let _ = tmux(&["kill-server"]);
372}
373
374/// The omnibar's ssh rows, end to end: a request over the socket has to reach
375/// tmux as a real window, named for the machine.
376///
377/// The host is `.invalid`, which RFC 2606 reserves and no resolver will answer
378/// for — the test is about the window the daemon opens, and it must not depend
379/// on anything being reachable. ssh failing in it is the expected outcome; the
380/// window survives that, which is the other half of what is being checked.
381#[tokio::test]
382async fn ssh_request_opens_a_window_named_for_the_host() {
383 if !Profile::tmux_available() {
384 eprintln!("skipping: tmux not installed");
385 return;
386 }
387 let sock = "termbridge-test-ssh";
388 let sess = "termbridge-e2e-ssh";
389 let tmux = |args: &[&str]| {
390 std::process::Command::new("tmux")
391 .args(["-L", sock])
392 .args(args)
393 .output()
394 };
395 let _ = tmux(&["kill-server"]);
396
397 let profile = Profile {
398 program: "tmux".into(),
399 args: ["-L", sock, "new-session", "-A", "-s", sess, "/bin/sh"]
400 .iter()
401 .map(|s| s.to_string())
402 .collect(),
403 };
404 let server = start(profile).await;
405 let mut ws = connect_authed(&server).await;
406 ws.send(Message::Text(
407 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
408 .to_string()
409 .into(),
410 ))
411 .await
412 .unwrap();
413 wait_until_ready(&mut ws, "boot-ssh").await;
414
415 ws.send(Message::Text(
416 serde_json::json!({
417 "type": "tmux", "cmd": "ssh",
418 "session": sess, "host": "collin@nowhere.invalid",
419 })
420 .to_string()
421 .into(),
422 ))
423 .await
424 .unwrap();
425
426 // Named for the machine, not for the login: `collin@` is the part that is
427 // the same on every one of them.
428 let mut names = String::new();
429 for _ in 0..50 {
430 tokio::time::sleep(Duration::from_millis(100)).await;
431 let out = tmux(&["list-windows", "-a", "-F", "#{window_name}"]).unwrap();
432 names = String::from_utf8_lossy(&out.stdout).into_owned();
433 if names.lines().any(|n| n == "nowhere.invalid") {
434 break;
435 }
436 }
437 assert!(
438 names.lines().any(|n| n == "nowhere.invalid"),
439 "no window for the host; windows were: {names:?}"
440 );
441
442 ws.close(None).await.unwrap();
443 let _ = tmux(&["kill-server"]);
444}
445
446// --- tmux session selection -------------------------------------------------
447
448/// Session names reach `execvp` as a separate argv element, so shell
449/// metacharacters cannot inject a command. A leading dash is the real hazard —
450/// tmux would read it as a flag — and the charset check backs that up.
451#[test]
452fn session_name_validation() {
453 use termbridge::pty::valid_session_name as v;
454
455 for good in ["browser", "work", "my-session", "proj_2", "A1"] {
456 assert_eq!(v(good).as_deref(), Some(good), "{good} should be accepted");
457 }
458
459 for bad in [
460 "",
461 " ",
462 "-C", // tmux would read this as a flag
463 "--help",
464 "a b", // tmux disallows
465 "a.b", // tmux disallows '.'
466 "a:b", // tmux disallows ':'
467 "a/b",
468 "a;id",
469 "a$(id)",
470 "a`id`",
471 "a|b",
472 "a&b",
473 "a\nb",
474 "a\0b",
475 "a'b",
476 "a\"b",
477 "../../etc/passwd",
478 "sess$IFS",
479 ] {
480 assert_eq!(v(bad), None, "{bad:?} must be rejected");
481 }
482
483 assert_eq!(v(&"x".repeat(65)), None, "absurd length must be rejected");
484 assert_eq!(v(" padded ").as_deref(), Some("padded"), "trims");
485}
486
487/// A rejected session name must fall back to the default, never reach tmux, and
488/// never change what program runs.
489#[tokio::test]
490async fn hostile_session_name_falls_back_and_cannot_inject() {
491 if !Profile::tmux_available() {
492 eprintln!("skipping: tmux not installed");
493 return;
494 }
495 let sock = "termbridge-inject";
496 let _ = std::process::Command::new("tmux")
497 .args(["-L", sock, "kill-server"])
498 .output();
499
500 // A tmux profile pinned to a private socket so the test is self-contained.
501 let profile = Profile {
502 program: "tmux".into(),
503 args: ["-L", sock, "new-session", "-A", "-s", "safe", "/bin/sh"]
504 .iter()
505 .map(|s| s.to_string())
506 .collect(),
507 };
508 let server = start(profile).await;
509 let mut ws = connect_authed(&server).await;
510
511 ws.send(Message::Text(
512 serde_json::json!({
513 "type": "open", "cols": 80, "rows": 24,
514 "session": "evil; touch /tmp/termbridge-pwned; #"
515 })
516 .to_string()
517 .into(),
518 ))
519 .await
520 .unwrap();
521
522 wait_until_ready(&mut ws, "boot-inject").await;
523 ws.send(Message::Binary(b"echo marker-$((6*7))\n".to_vec().into()))
524 .await
525 .unwrap();
526 wait_for(&mut ws, "marker-42").await;
527
528 assert!(
529 !std::path::Path::new("/tmp/termbridge-pwned").exists(),
530 "session name was interpreted by a shell"
531 );
532
533 let _ = std::process::Command::new("tmux")
534 .args(["-L", sock, "kill-server"])
535 .output();
536}
537
538/// A status frame describes the whole server, not just the session we are on:
539/// the sidebar draws sessions over their windows, so the windows of a session
540/// nobody is attached to have to be in the frame before it is selected.
541#[tokio::test]
542async fn status_frames_carry_the_windows_of_every_session() {
543 if !Profile::tmux_available() {
544 eprintln!("skipping: tmux not installed");
545 return;
546 }
547 let sock = "termbridge-nested-test";
548 let (here, elsewhere) = ("tb-nested-one", "tb-nested-two");
549 let tmux = |args: &[&str]| {
550 std::process::Command::new("tmux")
551 .args(["-L", sock])
552 .args(args)
553 .output()
554 };
555 let _ = tmux(&["kill-server"]);
556 let _ = tmux(&["new-session", "-d", "-s", elsewhere, "/bin/sh"]);
557 let _ = tmux(&[
558 "new-window",
559 "-t",
560 elsewhere,
561 "-n",
562 "second-window",
563 "/bin/sh",
564 ]);
565 // A group colour, set the way the sidebar sets one, so the frame has to
566 // carry it back. The session that has none must come back with none rather
567 // than with an empty string, which is what tells the panel to pick.
568 let _ = tmux(&["set-option", "-t", elsewhere, "@termbridge_color", "275"]);
569
570 let profile = Profile {
571 program: "tmux".into(),
572 args: ["-L", sock, "new-session", "-A", "-s", here, "/bin/sh"]
573 .iter()
574 .map(|s| s.to_string())
575 .collect(),
576 };
577 let server = start(profile).await;
578 let mut ws = connect_authed(&server).await;
579 ws.send(Message::Text(
580 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
581 .to_string()
582 .into(),
583 ))
584 .await
585 .unwrap();
586
587 let status = next_status(&mut ws, here).await;
588 let sessions = status["sessions"].as_array().unwrap().clone();
589 let find = |name: &str| {
590 sessions
591 .iter()
592 .find(|s| s["name"].as_str() == Some(name))
593 .unwrap_or_else(|| panic!("{name} missing from {sessions:?}"))
594 .clone()
595 };
596
597 // The session we are not attached to, with both of its windows and the
598 // name given to the second one.
599 let other = find(elsewhere);
600 let windows = other["windows"].as_array().unwrap();
601 assert_eq!(windows.len(), 2, "windows of {elsewhere}: {other:?}");
602 assert!(
603 windows
604 .iter()
605 .any(|w| w["name"].as_str() == Some("second-window")),
606 "window names should survive: {windows:?}"
607 );
608 assert!(other["id"].as_str().is_some_and(|id| id.starts_with('$')));
609 assert_eq!(other["color"], serde_json::json!("275"), "{other:?}");
610
611 let ours = find(here);
612 assert_eq!(ours["windows"].as_array().unwrap().len(), 1);
613 assert_eq!(ours["attached"], serde_json::json!(true));
614 // Unset, not empty: the panel tests for a colour by its absence.
615 assert_eq!(ours["color"], serde_json::Value::Null, "{ours:?}");
616
617 let _ = tmux(&["kill-server"]);
618}
619
620/// The status channel, end to end: the daemon's control-mode client reports
621/// which session we're on, lists the others, and moves the live client when the
622/// sidebar asks — all on a private tmux server so the user's own is untouched.
623#[tokio::test]
624async fn status_frames_track_the_session_and_switching_moves_the_client() {
625 if !Profile::tmux_available() {
626 eprintln!("skipping: tmux not installed");
627 return;
628 }
629 let sock = "termbridge-status-test";
630 let (first, second) = ("tb-status-one", "tb-status-two");
631 let tmux = |args: &[&str]| {
632 std::process::Command::new("tmux")
633 .args(["-L", sock])
634 .args(args)
635 .output()
636 };
637 let _ = tmux(&["kill-server"]);
638 let _ = tmux(&["new-session", "-d", "-s", second, "/bin/sh"]);
639
640 let profile = Profile {
641 program: "tmux".into(),
642 args: ["-L", sock, "new-session", "-A", "-s", first, "/bin/sh"]
643 .iter()
644 .map(|s| s.to_string())
645 .collect(),
646 };
647 let server = start(profile).await;
648 let mut ws = connect_authed(&server).await;
649 ws.send(Message::Text(
650 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
651 .to_string()
652 .into(),
653 ))
654 .await
655 .unwrap();
656
657 let on_first = next_status(&mut ws, first).await;
658 let names: Vec<&str> = on_first["sessions"]
659 .as_array()
660 .unwrap()
661 .iter()
662 .map(|s| s["name"].as_str().unwrap())
663 .collect();
664 assert!(
665 names.contains(&first) && names.contains(&second),
666 "switcher should see both sessions, got {names:?}"
667 );
668
669 // The whole point of doing this over control mode: no reconnect, no second
670 // pty, the same WebSocket keeps streaming.
671 ws.send(Message::Text(
672 serde_json::json!({"type": "tmux", "cmd": "switch", "session": second})
673 .to_string()
674 .into(),
675 ))
676 .await
677 .unwrap();
678 next_status(&mut ws, second).await;
679
680 let _ = tmux(&["kill-server"]);
681}
682
683/// Ctrl-D on the last shell of a session closes that session, not the sidebar.
684///
685/// tmux's default (`detach-on-destroy on`) would detach our client along with
686/// the session, which reaches the sidebar as EOF on the pty and a dead panel.
687/// `-f /dev/null` keeps the user's own tmux.conf out of it, so this tests the
688/// daemon's doing rather than their configuration.
689#[tokio::test]
690async fn ctrl_d_closes_the_session_without_dropping_the_client() {
691 if !Profile::tmux_available() {
692 eprintln!("skipping: tmux not installed");
693 return;
694 }
695 let sock = "termbridge-detach-e2e";
696 let (going, staying) = ("tb-detach-going", "tb-detach-staying");
697 let tmux = |args: &[&str]| {
698 std::process::Command::new("tmux")
699 .args(["-L", sock, "-f", "/dev/null"])
700 .args(args)
701 .output()
702 };
703 let _ = tmux(&["kill-server"]);
704 let _ = tmux(&["new-session", "-d", "-s", staying, "/bin/sh"]);
705
706 let profile = Profile {
707 program: "tmux".into(),
708 args: [
709 "-L",
710 sock,
711 "-f",
712 "/dev/null",
713 "new-session",
714 "-A",
715 "-s",
716 going,
717 "/bin/sh",
718 ]
719 .iter()
720 .map(|s| s.to_string())
721 .collect(),
722 };
723 let server = start(profile).await;
724 let mut ws = connect_authed(&server).await;
725 ws.send(Message::Text(
726 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
727 .to_string()
728 .into(),
729 ))
730 .await
731 .unwrap();
732
733 // The status frame is sent after the daemon has fixed the option, so
734 // seeing one for this session is what makes the ctrl-D below deterministic.
735 next_status(&mut ws, going).await;
736 wait_until_ready(&mut ws, "ready-to-exit").await;
737
738 // Ctrl-D: the shell exits, its pane goes, and with it the session.
739 ws.send(Message::Binary(b"\x04".to_vec().into()))
740 .await
741 .unwrap();
742
743 // The client lands on the other session instead of the socket closing.
744 next_status(&mut ws, staying).await;
745
746 let _ = tmux(&["kill-server"]);
747}
748
749/// Read frames until a `status` frame reports `session`, ignoring terminal
750/// output and the intermediate states tmux passes through.
751async fn next_status(
752 ws: &mut tokio_tungstenite::WebSocketStream<
753 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
754 >,
755 session: &str,
756) -> serde_json::Value {
757 let mut seen = Vec::new();
758 let found = tokio::time::timeout(Duration::from_secs(20), async {
759 while let Some(Ok(msg)) = ws.next().await {
760 let Message::Text(t) = msg else { continue };
761 let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else {
762 continue;
763 };
764 if v["type"] != "status" {
765 continue;
766 }
767 seen.push(v["session"].as_str().unwrap_or("").to_string());
768 if v["session"] == session {
769 return Some(v);
770 }
771 }
772 None
773 })
774 .await
775 .ok()
776 .flatten();
777
778 found.unwrap_or_else(|| panic!("never saw a status frame for {session:?}; saw {seen:?}"))
779}
780
781/// Every status frame is well formed, including the ones that land while the
782/// client is moving between sessions.
783///
784/// The regression: sourcing the sidebar's tmux overrides used to go over the
785/// control client, and in control mode `source-file` answers with two
786/// `%begin`/`%end` blocks rather than one. The control client pairs replies to
787/// commands by order, so the spare block was taken for the next command's
788/// answer and every reply after it was off by one — the sidebar was handed
789/// `list-clients` output as its session list, and drew session ids where names
790/// belong. It lasted a frame or two, which is what a switch looked like: a
791/// flash. So this asserts the *shape* of every frame, not just the last one.
792#[tokio::test]
793async fn frames_stay_well_formed_across_a_switch() {
794 if !Profile::tmux_available() {
795 eprintln!("skipping: tmux not installed");
796 return;
797 }
798 // Overrides of our own, so the test doesn't depend on the user having any
799 // — and doesn't touch theirs. Sourcing has to actually happen here: with no
800 // file to source there is no second block and nothing to regress.
801 let conf = tempfile::tempdir().unwrap();
802 std::fs::write(conf.path().join("browser.conf"), "set status off\n").unwrap();
803 std::fs::write(conf.path().join("browser-reset.conf"), "set -u status\n").unwrap();
804 // SAFETY: single-threaded setup before the server starts, and no other test
805 // reads this variable.
806 unsafe { std::env::set_var("TERMBRIDGE_TMUX_CONFIG_DIR", conf.path()) };
807
808 let sock = "termbridge-switch-shape";
809 let (here, there) = ("shape-one", "shape-two");
810 let tmux = |args: &[&str]| {
811 std::process::Command::new("tmux")
812 .args(["-L", sock])
813 .args(args)
814 .output()
815 };
816 let _ = tmux(&["kill-server"]);
817 let _ = tmux(&["new-session", "-d", "-s", there, "/bin/sh"]);
818
819 let profile = Profile {
820 program: "tmux".into(),
821 args: ["-L", sock, "new-session", "-A", "-s", here, "/bin/sh"]
822 .iter()
823 .map(|s| s.to_string())
824 .collect(),
825 };
826 let server = start(profile).await;
827 let mut ws = connect_authed(&server).await;
828 ws.send(Message::Text(
829 serde_json::json!({"type": "open", "cols": 80, "rows": 24})
830 .to_string()
831 .into(),
832 ))
833 .await
834 .unwrap();
835
836 next_status(&mut ws, here).await;
837 ws.send(Message::Text(
838 serde_json::json!({"type": "tmux", "cmd": "switch", "session": there})
839 .to_string()
840 .into(),
841 ))
842 .await
843 .unwrap();
844
845 // Everything the sidebar would have drawn for the next few seconds.
846 let mut frames = 0;
847 let _ = tokio::time::timeout(Duration::from_secs(4), async {
848 while let Some(Ok(msg)) = ws.next().await {
849 let Message::Text(t) = msg else { continue };
850 let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else {
851 continue;
852 };
853 if v["type"] != "status" {
854 continue;
855 }
856 frames += 1;
857 for s in v["sessions"].as_array().unwrap_or(&Vec::new()) {
858 let name = s["name"].as_str().unwrap_or_default();
859 let id = s["id"].as_str().unwrap_or_default();
860 assert!(
861 name == here || name == there,
862 "session name is not a session name: {s} in {v}"
863 );
864 assert!(id.starts_with('$'), "session id is not an id: {s} in {v}");
865 assert!(
866 !s["windows"].as_array().unwrap_or(&Vec::new()).is_empty(),
867 "a session with no windows does not exist: {s} in {v}"
868 );
869 }
870 }
871 })
872 .await;
873 assert!(frames > 0, "no status frames arrived at all");
874
875 let _ = tmux(&["kill-server"]);
876}