use super::*;
use crate::protocol::{self, ServerMsg};
use render::RenderCache;
fn screen_lines(screen: &Screen) -> Vec<String> {
screen
.grid
.visible_rows()
.map(|row| {
let s: String = row.iter().map(|c| c.c).collect();
s.trim_end().to_string()
})
.collect()
}
fn history_texts(screen: &Screen) -> Vec<String> {
screen
.get_history()
.iter()
.map(|b| strip_ansi(b))
.collect()
}
fn strip_ansi(bytes: &[u8]) -> String {
let s = String::from_utf8_lossy(bytes);
let mut out = String::new();
let mut in_esc = false;
for ch in s.chars() {
if in_esc {
if ch.is_ascii_alphabetic() || ch == 'm' {
in_esc = false;
}
continue;
}
if ch == '\x1b' {
in_esc = true;
continue;
}
if ch >= ' ' {
out.push(ch);
}
}
out.trim_end().to_string()
}
fn all_content(screen: &Screen) -> Vec<String> {
let mut lines = history_texts(screen);
lines.extend(
screen_lines(screen)
.into_iter()
.filter(|s| !s.is_empty()),
);
lines
}
fn write_labeled_lines(screen: &mut Screen, count: usize) {
for i in 1..=count {
if i < count {
screen.process(format!("L{:02}\r\n", i).as_bytes());
} else {
screen.process(format!("L{:02}", i).as_bytes());
}
}
}
fn simulate_reattach(screen: &Screen) -> (Vec<Vec<u8>>, Vec<u8>) {
let hist = screen.get_history();
let mut render_data = Vec::new();
if !hist.is_empty() {
render_data.extend_from_slice(b"\x1b[");
style::write_u16(&mut render_data, screen.grid.rows);
render_data.extend_from_slice(b";1H");
render_data.extend(
std::iter::repeat_n(b'\n', screen.grid.rows.saturating_sub(1) as usize),
);
}
let mut cache = RenderCache::new();
render_data.extend_from_slice(&screen.render(true, &mut cache));
(hist, render_data)
}
fn client_write_history(history: &[Vec<u8>]) -> Vec<u8> {
let mut out = Vec::new();
for line in history {
out.extend_from_slice(line);
out.extend_from_slice(b"\r\n");
}
out
}
#[test]
fn history_and_screen_no_overlap() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
let visible = screen_lines(&screen);
assert_eq!(hist.len(), 5, "expected 5 history lines, got {:?}", hist);
assert!(
visible[0].contains("L06"),
"screen row 0 should be L06, got: '{}'",
visible[0]
);
assert!(
visible[2].contains("L08"),
"screen row 2 should be L08, got: '{}'",
visible[2]
);
for h in &hist {
for v in &visible {
if !v.is_empty() {
assert_ne!(
h, v,
"line '{}' appears in both history and screen",
h
);
}
}
}
}
#[test]
fn pending_scrollback_drained_before_reattach() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 6);
let first = screen.take_pending_scrollback();
assert!(
!first.is_empty(),
"first take should have pending scrollback"
);
let second = screen.take_pending_scrollback();
assert!(
second.is_empty(),
"second take should be empty after drain"
);
let hist = screen.get_history();
assert!(
!hist.is_empty(),
"get_history should still return scrollback after pending drain"
);
}
#[test]
fn history_ordering_preserved_with_many_lines() {
let mut screen = Screen::new(20, 3, 5000);
for i in 1..=200 {
if i < 200 {
screen.process(format!("LINE{:04}\r\n", i).as_bytes());
} else {
screen.process(format!("LINE{:04}", i).as_bytes());
}
}
let _ = screen.take_pending_scrollback();
let hist = history_texts(&screen);
assert_eq!(hist.len(), 197);
for (i, line) in hist.iter().enumerate() {
let expected = format!("LINE{:04}", i + 1);
assert!(
line.contains(&expected),
"history line {} should contain '{}', got: '{}'",
i,
expected,
line
);
}
}
#[test]
fn resize_expand_moves_scrollback_to_screen_no_duplication() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 10);
let _ = screen.take_pending_scrollback();
assert_eq!(screen.get_history().len(), 7);
screen.resize(10, 6);
let all = all_content(&screen);
assert_eq!(all.len(), 10, "total lines after expand: {:?}", all);
for (i, line) in all.iter().enumerate() {
let expected = format!("L{:02}", i + 1);
assert!(
line.contains(&expected),
"line {} should be '{}', got: '{}'",
i,
expected,
line
);
}
assert_eq!(
screen.get_history().len(),
4,
"scrollback should have 4 lines after restoring 3"
);
}
#[test]
fn resize_shrink_then_expand_roundtrip_no_duplication() {
let mut screen = Screen::new(10, 5, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
let content_before = all_content(&screen);
assert_eq!(content_before.len(), 8);
screen.resize(10, 3);
screen.resize(10, 5);
let content_after = all_content(&screen);
let unique: std::collections::HashSet<&String> = content_after.iter().collect();
assert_eq!(
unique.len(),
content_after.len(),
"no duplicates allowed after shrink/expand: {:?}",
content_after
);
}
#[test]
fn stale_pending_scrollback_only_new_lines() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"A1\r\nA2\r\nA3\r\nA4\r\nA5");
let batch1 = screen.take_pending_scrollback();
let batch1_texts: Vec<String> = batch1.iter().map(|b| strip_ansi(b)).collect();
assert_eq!(batch1_texts.len(), 2, "first batch: {:?}", batch1_texts);
assert!(batch1_texts[0].contains("A1"));
assert!(batch1_texts[1].contains("A2"));
screen.process(b"\r\nA6");
let batch2 = screen.take_pending_scrollback();
let batch2_texts: Vec<String> = batch2.iter().map(|b| strip_ansi(b)).collect();
assert_eq!(batch2_texts.len(), 1, "second batch: {:?}", batch2_texts);
assert!(
batch2_texts[0].contains("A3"),
"second batch should only have A3, got: '{}'",
batch2_texts[0]
);
}
#[test]
fn history_render_flush_newlines_match_rows() {
for rows in [3u16, 5, 10, 24] {
let mut screen = Screen::new(80, rows, 100);
for i in 1..=(rows as usize + 5) {
if i < (rows as usize + 5) {
screen.process(format!("line{}\r\n", i).as_bytes());
} else {
screen.process(format!("line{}", i).as_bytes());
}
}
let _ = screen.take_pending_scrollback();
let (hist, screen_update) = simulate_reattach(&screen);
assert!(!hist.is_empty(), "should have history for rows={}", rows);
let expected_prefix = format!("\x1b[{};1H", rows);
let prefix_bytes = expected_prefix.as_bytes();
assert!(
screen_update.starts_with(prefix_bytes),
"rows={}: update should start with cursor-to-bottom '{}', got: {:?}",
rows,
expected_prefix,
String::from_utf8_lossy(&screen_update[..20.min(screen_update.len())])
);
let after_cursor = &screen_update[prefix_bytes.len()..];
let newline_count = after_cursor.iter().take_while(|&&b| b == b'\n').count();
assert_eq!(
newline_count,
(rows - 1) as usize,
"rows={}: expected {} flush newlines, got {}",
rows,
rows - 1,
newline_count
);
}
}
#[test]
fn resize_between_reattach_preserves_content() {
let mut screen = Screen::new(10, 5, 100);
write_labeled_lines(&mut screen, 12);
let _ = screen.take_pending_scrollback();
let content_before = all_content(&screen);
assert_eq!(content_before.len(), 12);
let (hist_before, _) = simulate_reattach(&screen);
assert_eq!(hist_before.len(), 7);
screen.resize(10, 3);
let _ = screen.take_pending_scrollback(); let (hist_after, screen_update_after) = simulate_reattach(&screen);
let content_after = all_content(&screen);
let unique: std::collections::HashSet<&String> = content_after.iter().collect();
assert_eq!(
unique.len(),
content_after.len(),
"no duplicates after resize between reattach: {:?}",
content_after
);
assert!(
!hist_after.is_empty(),
"history should not be empty after resize"
);
let update_text = String::from_utf8_lossy(&screen_update_after);
assert!(
update_text.contains("\x1b[3;1H"),
"flush should position cursor at new bottom row (3)"
);
}
#[test]
fn resize_expand_between_reattach_restores_scrollback() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
assert_eq!(screen.get_history().len(), 5);
screen.resize(10, 8);
assert_eq!(
screen.get_history().len(),
0,
"all scrollback should be restored after expanding to 8 rows"
);
let visible = screen_lines(&screen);
for i in 1..=8 {
let expected = format!("L{:02}", i);
assert!(
visible.iter().any(|v| v.contains(&expected)),
"L{:02} should be visible after expand, screen: {:?}",
i,
visible
);
}
let (hist, screen_update) = simulate_reattach(&screen);
assert!(hist.is_empty(), "no history after full restore");
assert!(
screen_update.starts_with(b"\x1b[?2026h"),
"no-history reattach should start with sync begin"
);
}
#[test]
fn history_message_round_trip() {
let lines = vec![
b"line one".to_vec(),
b"line two with \x1b[1mbold\x1b[0m".to_vec(),
b"line three".to_vec(),
];
let msg = ServerMsg::History(lines.clone());
let encoded = protocol::encode(&msg).unwrap();
let (data, consumed) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
assert_eq!(consumed, encoded.len());
let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
match decoded {
ServerMsg::History(decoded_lines) => {
assert_eq!(decoded_lines.len(), 3);
assert_eq!(decoded_lines[0], b"line one");
assert_eq!(decoded_lines[2], b"line three");
}
other => panic!("expected History, got {:?}", other),
}
}
#[test]
fn screen_update_message_round_trip() {
let update_data = b"\x1b[?2026h\x1b[?25l\x1b[2J\x1b[HHello\x1b[?2026l".to_vec();
let msg = ServerMsg::ScreenUpdate(update_data.clone());
let encoded = protocol::encode(&msg).unwrap();
let (data, _) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
match decoded {
ServerMsg::ScreenUpdate(decoded_data) => {
assert_eq!(decoded_data, update_data);
}
other => panic!("expected ScreenUpdate, got {:?}", other),
}
}
#[test]
fn history_chunking_round_trip() {
let mut all_lines = Vec::new();
for i in 0..500 {
all_lines.push(format!("history line {:04}", i).into_bytes());
}
let size_limit = protocol::codec::MAX_FRAME_SIZE / 2;
let mut chunks: Vec<Vec<Vec<u8>>> = Vec::new();
let mut chunk = Vec::new();
let mut chunk_size = 0;
for line in &all_lines {
let line_size = line.len() + 16;
if chunk_size + line_size > size_limit && !chunk.is_empty() {
chunks.push(std::mem::take(&mut chunk));
chunk_size = 0;
}
chunk_size += line_size;
chunk.push(line.clone());
}
if !chunk.is_empty() {
chunks.push(chunk);
}
let mut reassembled = Vec::new();
for chunk_lines in &chunks {
let msg = ServerMsg::History(chunk_lines.clone());
let encoded = protocol::encode(&msg).unwrap();
let (data, _) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
match decoded {
ServerMsg::History(lines) => reassembled.extend(lines),
other => panic!("expected History, got {:?}", other),
}
}
assert_eq!(reassembled.len(), all_lines.len());
for (i, line) in reassembled.iter().enumerate() {
assert_eq!(
line, &all_lines[i],
"line {} mismatch after chunked round-trip",
i
);
}
}
#[test]
fn scrollback_line_message_round_trip() {
let line = b"\x1b[1mcolored output\x1b[0m".to_vec();
let msg = ServerMsg::ScrollbackLine(line.clone());
let encoded = protocol::encode(&msg).unwrap();
let (data, _) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
match decoded {
ServerMsg::ScrollbackLine(decoded_line) => {
assert_eq!(decoded_line, line);
}
other => panic!("expected ScrollbackLine, got {:?}", other),
}
}
#[test]
fn e2e_reattach_history_then_screen() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 8);
let _ = screen.take_pending_scrollback();
let (hist, screen_update) = simulate_reattach(&screen);
assert_eq!(hist.len(), 5, "should have 5 history lines");
let mut stdout = Vec::new();
for line in &hist {
stdout.extend_from_slice(line);
stdout.extend_from_slice(b"\r\n");
}
stdout.extend_from_slice(&screen_update);
let stdout_text = String::from_utf8_lossy(&stdout);
let pos_l01 = stdout_text.find("L01").expect("L01 should be in output");
let pos_l05 = stdout_text.find("L05").expect("L05 should be in output");
let pos_clear = stdout_text.find("\x1b[2J").expect("screen clear should be in output");
assert!(
pos_l01 < pos_l05,
"history lines should be in order"
);
assert!(
pos_l05 < pos_clear,
"history should appear before screen clear"
);
let after_clear = &stdout_text[pos_clear..];
assert!(
after_clear.contains("L06"),
"screen should contain L06 after clear"
);
assert!(
after_clear.contains("L08"),
"screen should contain L08 after clear"
);
for label in &["L01", "L02", "L03", "L04", "L05"] {
assert!(
!after_clear.contains(label),
"'{}' should not appear in screen portion (after clear)",
label
);
}
}
#[test]
fn e2e_reattach_no_history_no_flush() {
let mut screen = Screen::new(10, 3, 100);
screen.process(b"Hello\r\nWorld");
let (hist, screen_update) = simulate_reattach(&screen);
assert!(hist.is_empty(), "should have no history");
assert!(
screen_update.starts_with(b"\x1b[?2026h"),
"no-history reattach should start with sync begin, got: {:?}",
String::from_utf8_lossy(&screen_update[..20.min(screen_update.len())])
);
}
#[test]
fn e2e_reattach_with_styled_history() {
let mut screen = Screen::new(20, 3, 100);
screen.process(b"\x1b[1;31mRED BOLD\x1b[0m normal\r\n");
screen.process(b"\x1b[32mGREEN\x1b[0m\r\n");
screen.process(b"plain1\r\n");
screen.process(b"plain2\r\n");
screen.process(b"visible");
let _ = screen.take_pending_scrollback();
let (hist, _) = simulate_reattach(&screen);
assert_eq!(hist.len(), 2, "2 lines should be in history");
let line0 = &hist[0];
let line0_text = String::from_utf8_lossy(line0);
assert!(
line0_text.contains("RED BOLD"),
"history should preserve text content"
);
assert!(
line0_text.contains("\x1b["),
"history should preserve SGR escape codes"
);
}
#[test]
fn e2e_reattach_protocol_encode_decode_sequence() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 6);
let _ = screen.take_pending_scrollback();
let (hist, screen_update) = simulate_reattach(&screen);
let mut wire = Vec::new();
wire.extend(
protocol::encode(&ServerMsg::Connected {
name: "test".into(),
new_session: false,
})
.unwrap(),
);
if !hist.is_empty() {
wire.extend(protocol::encode(&ServerMsg::History(hist)).unwrap());
}
wire.extend(protocol::encode(&ServerMsg::ScreenUpdate(screen_update)).unwrap());
let mut offset = 0;
let mut messages = Vec::new();
while offset < wire.len() {
let (data, consumed) = protocol::codec::decode_frame(&wire[offset..])
.unwrap()
.expect("should decode complete frame");
let msg: ServerMsg = protocol::codec::decode(data).unwrap();
messages.push(msg);
offset += consumed;
}
assert!(matches!(messages[0], ServerMsg::Connected { .. }));
assert!(matches!(messages[1], ServerMsg::History(_)));
assert!(matches!(messages[2], ServerMsg::ScreenUpdate(_)));
let mut stdout = Vec::new();
for msg in &messages {
match msg {
ServerMsg::History(lines) => {
for line in lines {
stdout.extend_from_slice(line);
stdout.extend_from_slice(b"\r\n");
}
}
ServerMsg::ScreenUpdate(data) => {
stdout.extend_from_slice(data);
}
_ => {}
}
}
let text = String::from_utf8_lossy(&stdout);
assert!(text.contains("L01"), "L01 should be in output");
assert!(text.contains("L03"), "L03 should be in output");
let clear_pos = text.find("\x1b[2J").expect("screen clear");
let after_clear = &text[clear_pos..];
assert!(after_clear.contains("L04"), "L04 should be on screen");
assert!(after_clear.contains("L06"), "L06 should be on screen");
}
#[test]
fn e2e_resize_between_reattach_cycles() {
let mut screen = Screen::new(10, 5, 100);
write_labeled_lines(&mut screen, 10);
let _ = screen.take_pending_scrollback();
let (hist1, update1) = simulate_reattach(&screen);
assert_eq!(hist1.len(), 5, "first reattach: 5 history lines");
let mut stdout1 = client_write_history(&hist1);
stdout1.extend_from_slice(&update1);
let text1 = String::from_utf8_lossy(&stdout1);
assert!(text1.contains("L01"), "first reattach should have L01 in history");
screen.resize(10, 3);
let _ = screen.take_pending_scrollback();
let (hist2, update2) = simulate_reattach(&screen);
let mut stdout2 = client_write_history(&hist2);
stdout2.extend_from_slice(&update2);
let text2 = String::from_utf8_lossy(&stdout2);
assert!(
hist2.len() >= hist1.len(),
"shrink should not reduce scrollback, before={}, after={}",
hist1.len(),
hist2.len()
);
let clear_pos2 = text2.find("\x1b[2J").expect("screen clear in second reattach");
let history_portion = &text2[..clear_pos2];
let screen_portion = &text2[clear_pos2..];
for i in 1..=10 {
let label = format!("L{:02}", i);
let in_hist = history_portion.contains(&label);
let in_screen = screen_portion.contains(&label);
assert!(
!(in_hist && in_screen),
"'{}' appears in both history and screen portions",
label
);
}
screen.resize(10, 8);
let _ = screen.take_pending_scrollback();
let (hist3, update3) = simulate_reattach(&screen);
let mut stdout3 = client_write_history(&hist3);
stdout3.extend_from_slice(&update3);
let _text3 = String::from_utf8_lossy(&stdout3);
assert!(
hist3.len() < hist2.len(),
"expand should reduce scrollback, before={}, after={}",
hist2.len(),
hist3.len()
);
if !hist3.is_empty() {
let cursor_prefix = format!("\x1b[{};1H", 8);
assert!(
String::from_utf8_lossy(&update3).contains(&cursor_prefix),
"flush should use new row count (8)"
);
}
let all = all_content(&screen);
let unique: std::collections::HashSet<&String> = all.iter().collect();
assert_eq!(
unique.len(),
all.len(),
"no duplicates after multiple resize+reattach cycles: {:?}",
all
);
}
#[test]
fn e2e_scrollback_during_session_then_reattach() {
let mut screen = Screen::new(10, 3, 100);
write_labeled_lines(&mut screen, 5);
let pending = screen.take_pending_scrollback();
assert_eq!(pending.len(), 2, "2 lines scrolled off");
let mut cache = RenderCache::new();
let atomic_update = screen.render_with_scrollback(&pending, &mut cache);
let atomic_text = String::from_utf8_lossy(&atomic_update);
assert!(atomic_text.contains("L01"), "scrollback should contain L01");
let clear_pos = atomic_text
.find("\x1b[2J")
.expect("atomic update should have screen clear");
let l01_pos = atomic_text
.find("L01")
.expect("L01 should be in output");
assert!(
l01_pos < clear_pos,
"scrollback content should precede screen clear"
);
let (hist, screen_update) = simulate_reattach(&screen);
let hist_texts: Vec<String> = hist.iter().map(|b| strip_ansi(b)).collect();
assert!(
hist_texts.iter().any(|t| t.contains("L01")),
"L01 should be in reattach history: {:?}",
hist_texts
);
let mut stdout = client_write_history(&hist);
stdout.extend_from_slice(&screen_update);
let text = String::from_utf8_lossy(&stdout);
assert!(text.contains("L01"), "reattach should include L01");
assert!(text.contains("L05"), "reattach should include L05");
}
#[test]
fn e2e_reattach_wide_terminal_to_narrow() {
let mut screen = Screen::new(40, 5, 100);
for i in 1..=8 {
let line = format!("LINE{:02}--padding-to-fill-wide-terminal---", i);
if i < 8 {
screen.process(format!("{}\r\n", line).as_bytes());
} else {
screen.process(line.as_bytes());
}
}
let _ = screen.take_pending_scrollback();
let (hist_wide, _) = simulate_reattach(&screen);
let hist_wide_texts: Vec<String> = hist_wide.iter().map(|b| strip_ansi(b)).collect();
screen.resize(10, 5);
let _ = screen.take_pending_scrollback();
let (hist_narrow, screen_update) = simulate_reattach(&screen);
let hist_narrow_texts: Vec<String> = hist_narrow.iter().map(|b| strip_ansi(b)).collect();
for line in &hist_narrow_texts[..hist_wide_texts.len().min(hist_narrow_texts.len())] {
assert!(
line.contains("LINE"),
"old scrollback line should still have content: '{}'",
line
);
}
let update_text = String::from_utf8_lossy(&screen_update);
assert!(
update_text.contains("\x1b[?2026h"),
"screen update should have sync begin"
);
assert!(
update_text.contains("\x1b[?2026l"),
"screen update should have sync end"
);
}