blob: 528136465f6c2f864c39a39bde802f4835783ef2 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use maka_agent_facts::terminal::TerminalSize;
use maka_process::terminal::Screen;
#[cfg(unix)]
#[tokio::test]
async fn real_pty_queries_and_mode_aware_input_cross_the_screen_cut() {
use maka_agent_facts::terminal::input::InputAction;
use maka_agent_facts::terminal::input::encode_actions;
use maka_process::pty;
use serde_json::json;
use std::time::Duration;
let expected = "\u{1b}OA中\u{1b}[<0;2;2M\u{1b}[<0;2;2m\r";
let hex = expected
.bytes()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let mut command = pty::PtyCommand::new("/bin/sh", std::env::current_dir().unwrap());
command.args([
"-c",
&format!(
r#"
stty raw -echo
printf '\033[6n'
reply=$(dd bs=1 count=6 2>/dev/null | od -An -tx1 | tr -d ' \n')
test "$reply" = 1b5b313b3152 || exit 3
printf '\033[?1h\033[?1006h\033[?1002hready'
input=$(dd bs=1 count={} 2>/dev/null | od -An -tx1 | tr -d ' \n')
test "$input" = {hex} || exit 4
printf '\r\nverified'
exit 42
"#,
expected.len()
),
]);
let size = TerminalSize::new(80, 24).unwrap();
let (mut child, io) = pty::spawn(command, size).await.unwrap();
let mut screen = Screen::new(size);
let actions = json!([
{"type":"key","key":"arrow_up"},
{"type":"text","text":"中"},
{"type":"mouse","event":"click","button":"left","x":1,"y":1},
{"type":"key","key":"enter"}
])
.as_array()
.unwrap()
.iter()
.cloned()
.map(InputAction::parse)
.collect::<Result<Vec<_>, _>>()
.unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
let mut sent = false;
let mut bytes = [0; 1024];
loop {
let count = io.read(&mut bytes).await.unwrap();
if count == 0 {
break;
}
// This fixture emits ASCII; native UTF-8 input is checked by the
// child byte-for-byte, independent of read chunk boundaries.
let replies = screen
.write(std::str::from_utf8(&bytes[..count]).unwrap())
.unwrap();
write_pty(&io, replies.as_bytes()).await;
let cut = screen.preview(50 * 1024).unwrap();
if !sent && cut.screen.contains("ready") {
let input = encode_actions(&actions, cut.input, cut.size).unwrap();
assert_eq!(input, expected);
write_pty(&io, input.as_bytes()).await;
sent = true;
}
}
assert!(sent);
assert_eq!(child.wait().await.unwrap().code(), Some(42));
assert!(
screen
.preview(50 * 1024)
.unwrap()
.screen
.contains("verified")
);
})
.await
.unwrap();
async fn write_pty(io: &pty::PtyIo, mut input: &[u8]) {
while !input.is_empty() {
let count = io.write(input).await.unwrap();
assert!(count > 0);
input = &input[count..];
}
}
}
#[test]
fn unicode_modes_alternate_screen_reflow_and_history_are_observable() {
let mut screen = Screen::new(TerminalSize::new(10, 3).unwrap());
assert_eq!(
screen.write("hello 中文😀\r\nnext\x1b[6n").unwrap(),
"\x1b[3;5R"
);
let first = screen.preview(50 * 1024).unwrap();
assert!(first.screen.contains("中文"));
screen
.write("\x1b[?1h\x1b[?1002h\x1b[?1006h\x1b[?25l")
.unwrap();
let modes = screen.preview(50 * 1024).unwrap();
assert!(!modes.cursor.visible);
assert!(modes.input.application_cursor_keys_mode);
assert_eq!(
modes.input.mouse_tracking_mode,
maka_agent_facts::terminal::MouseTracking::Drag
);
screen.write("𝄞e\u{301}").unwrap();
screen
.write("\x1b[?1049h\x1b[HALT\r\nsecond\x1b[?1049l")
.unwrap();
let restored = screen.preview(50 * 1024).unwrap();
assert!(!restored.alternate_screen);
assert_eq!(
restored.last_alternate_screen.as_deref(),
Some("ALT\nsecond")
);
assert!(restored.screen.contains("next"));
screen.resize(TerminalSize::new(5, 4).unwrap()).unwrap();
assert_eq!(screen.preview(50 * 1024).unwrap().size.cols(), 5);
screen.write("\x1bc").unwrap();
screen.write(&"line\r\n".repeat(520)).unwrap();
let history = screen.preview(50 * 1024).unwrap();
assert!(!history.truncated);
assert!(history.scrollback.lines().count() > 500);
assert!(history.screen.contains("line"));
let mut reflow = Screen::new(TerminalSize::new(6, 3).unwrap());
reflow.write("abcdefghijkl").unwrap();
reflow.resize(TerminalSize::new(4, 4).unwrap()).unwrap();
let resized = reflow.preview(50 * 1024).unwrap();
assert_eq!(
format!("{}{}", resized.scrollback, resized.screen).replace('\n', ""),
"abcdefghijkl"
);
}
#[test]
fn unicode_cuts_gaps_and_adversarial_streams_preserve_bounded_state() {
let size = TerminalSize::new(10, 3).unwrap();
let mut screen = Screen::new(size);
for c in "中文😀e\u{301}".chars() {
screen.write(c.encode_utf8(&mut [0; 4])).unwrap();
}
assert_eq!(screen.preview(50 * 1024).unwrap().screen, "中文😀e\u{301}");
screen.write("\x1b]52;c;unterminated").unwrap();
screen.reset_after_gap();
screen.write("after").unwrap();
let snapshot = screen.preview(50 * 1024).unwrap();
assert_eq!(snapshot.screen, "after");
assert!(snapshot.truncated);
screen.write(&"x".repeat(65537)).unwrap();
assert!(screen.preview(50 * 1024).unwrap().screen.contains("xxxxx"));
for bytes in [
b"\x1b]0;"
.iter()
.copied()
.chain(std::iter::repeat_n(b'x', 65540))
.collect::<Vec<_>>(),
format!("x{}", "\u{301}".repeat(100)).into_bytes(),
] {
let mut screen = Screen::new(size);
let mut failed = false;
for chunk in bytes.chunks(1024) {
if screen.write(std::str::from_utf8(chunk).unwrap()).is_err() {
failed = true;
break;
}
}
assert!(failed, "unbounded sequence accepted");
assert!(screen.preview(50 * 1024).is_err());
}
// Suppressed OSC capabilities cannot emit clipboard data or grow a title stack.
let mut safe = Screen::new(size);
assert_eq!(
safe.write("\x1b]52;c;aGVsbG8=\x07\x1b]52;c;?\x07OK\x1b[5n")
.unwrap(),
"\x1b[0n"
);
assert_eq!(safe.preview(50 * 1024).unwrap().screen, "OK");
// Large historical grapheme clusters must not crowd the current viewport
// out of the bounded snapshot.
let mut history = Screen::new(TerminalSize::new(240, 4).unwrap());
let row = format!("{}\r\n", format!("x{}", "\u{301}".repeat(32)).repeat(240));
for index in 0..150 {
history
.write(&row)
.unwrap_or_else(|error| panic!("row {index}: {error}"));
}
history.write("current-frame").unwrap();
let snapshot = history.preview(50 * 1024).unwrap();
assert!(snapshot.truncated);
assert!(snapshot.screen.contains("current-frame"));
assert!(snapshot.screen.len() + snapshot.scrollback.len() <= 2 * 1024 * 1024);
}
#[test]
fn large_ignored_clipboard_strings_preserve_screen_replies_and_following_output() {
// A native 64 KiB clipboard copy has an 87 KiB base64 wire payload. It must
// not consume Screen's OSC accumulator: clipboard effects are not supported.
let decoded_bytes = 64 * 1024;
let encoded = "YWFh".repeat(decoded_bytes / 3) + "YQ==";
for ending in ["\x07", "\x1b\\", "\x18", "\x1a", "\x1b[31m"] {
let mut screen = Screen::new(TerminalSize::new(20, 3).unwrap());
screen.write("before").unwrap();
// Split the command prefix, including an ignored C0, across writes.
for prefix in ["\x1b", "]", "5", "\0", "2", ";", "c", ";"] {
assert_eq!(screen.write(prefix).unwrap(), "");
}
for chunk in encoded.as_bytes().chunks(8192) {
assert_eq!(
screen.write(std::str::from_utf8(chunk).unwrap()).unwrap(),
""
);
}
for character in ending.chars() {
assert_eq!(
screen.write(character.encode_utf8(&mut [0; 4])).unwrap(),
""
);
}
assert_eq!(screen.write("after\x1b[5n").unwrap(), "\x1b[0n");
assert_eq!(screen.preview(50 * 1024).unwrap().screen, "beforeafter");
assert_eq!(screen.preview(50 * 1024).unwrap().cursor.x, 11);
}
}
#[test]
fn large_valid_writes_and_clamped_scrolls_do_not_fail_the_parser() {
let mut screen = Screen::new(TerminalSize::new(80, 24).unwrap());
screen.write(&"x".repeat(128 * 1024)).unwrap();
screen
.write("x\x1b[65535b\x1b[65535b\x1b[65535S\x1b[Hstill-live")
.unwrap();
assert!(
screen
.preview(50 * 1024)
.unwrap()
.screen
.contains("still-live")
);
assert!(TerminalSize::new(1024, 64).is_ok());
assert!(TerminalSize::new(64, 1024).is_ok());
assert!(TerminalSize::new(1024, 65).is_err());
assert!(TerminalSize::new(1025, 1).is_err());
}
#[test]
fn narrowing_a_full_history_keeps_the_same_cell_allowance_through_reflow() {
let mut screen = Screen::new(TerminalSize::new(80, 24).unwrap());
let line = format!("FIRST{}", "x".repeat(75));
screen
.write(&format!(
"{line}\r\n{}",
format!("{}\r\n", "y".repeat(80)).repeat(9_999)
))
.unwrap();
let before = screen.display();
assert_eq!(before.scrollback.first().unwrap()[0].text, line);
screen.resize(TerminalSize::new(40, 24).unwrap()).unwrap();
let after = screen.display();
assert!(after.scrollback.len() > 10_000);
assert!(after.text(2 * 1024 * 1024).scrollback.starts_with(&line));
assert!(after.scrollback.len() * 40 <= maka_agent_facts::terminal::HISTORY_CELLS);
}