#![cfg(unix)]
use std::io::{Read, Write};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use prismtty::highlight::strip_ansi;
#[test]
fn pasted_line_is_fully_visible_without_extra_input() {
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty"));
builder.arg("sh");
builder.arg("-c");
builder.arg("printf 'READY\\n'; exec cat");
let mut child = pair.slave.spawn_command(builder).expect("spawn ptty cat");
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("clone reader");
let mut writer = pair.master.take_writer().expect("take writer");
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || {
let mut acc = Vec::new();
let mut buf = [0u8; 256];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
acc.extend_from_slice(&buf[..n]);
let visible = String::from_utf8_lossy(&strip_ansi(&acc)).into_owned();
if tx.send(visible).is_err() {
break;
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
let ready_deadline = Instant::now() + Duration::from_secs(5);
let mut visible = String::new();
while Instant::now() < ready_deadline {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(latest) => {
visible = latest;
if visible.contains("READY") {
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
assert!(
visible.contains("READY"),
"wrapped command was not ready before paste; saw: {visible:?}"
);
let paste = b"update add test.example.com 3600 A 192.0.2.1";
writer.write_all(paste).expect("write paste");
writer.flush().expect("flush paste");
let target = "update add test.example.com 3600 A 192.0.2.1";
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(latest) => {
visible = latest;
if visible.contains(target) {
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
let _ = child.wait();
assert!(
visible.contains(target),
"pasted line never fully surfaced without extra input; saw: {visible:?}"
);
}
fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize {
if needle.is_empty() || haystack.len() < needle.len() {
return 0;
}
haystack
.windows(needle.len())
.filter(|w| *w == needle)
.count()
}
fn contains_sgr_span(haystack: &[u8], token: &[u8]) -> bool {
let mut rest = haystack;
while let Some(esc_idx) = rest.iter().position(|byte| *byte == 0x1b) {
let candidate = &rest[esc_idx..];
let Some(m_idx) = candidate.iter().position(|byte| *byte == b'm') else {
return false;
};
if candidate[m_idx + 1..].starts_with(token) {
return true;
}
rest = &candidate[1..];
}
false
}
#[test]
fn split_program_output_token_keeps_single_highlight_span() {
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty"));
builder.arg("-p");
builder.arg("cisco");
builder.arg("sh");
builder.arg("-c");
builder
.arg("printf 'show: Vlan11'; sleep 0.25; printf '91 New TZ GW to Internal\\n'; sleep 0.3");
let mut child = pair.slave.spawn_command(builder).expect("spawn ptty");
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("clone reader");
let (tx, rx) = mpsc::channel::<Vec<u8>>();
thread::spawn(move || {
let mut acc = Vec::new();
let mut buf = [0u8; 256];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
acc.extend_from_slice(&buf[..n]);
if tx.send(acc.clone()).is_err() {
break;
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
let deadline = Instant::now() + Duration::from_secs(5);
let mut out = Vec::new();
while Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(latest) => {
out = latest;
if contains_sgr_span(&out, b"Vlan1191") {
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
let _ = child.wait();
assert!(
contains_sgr_span(&out, b"Vlan1191"),
"split program-output token lost its single highlight span; saw: {:?}",
String::from_utf8_lossy(&out)
);
}
fn echo_off_split_stream_while_typing(typed: &'static [u8]) -> Vec<u8> {
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
let mut builder = CommandBuilder::new(env!("CARGO_BIN_EXE_ptty"));
builder.arg("-p");
builder.arg("cisco");
builder.arg("sh");
builder.arg("-c");
builder.arg(
"stty -echo; i=0; while [ $i -lt 12 ]; do printf 'aaaaaaaa Vlan11'; \
sleep 0.12; printf '91 bbbb\\n'; sleep 0.12; i=$((i+1)); done",
);
let mut child = pair.slave.spawn_command(builder).expect("spawn ptty");
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("clone reader");
let mut writer = pair.master.take_writer().expect("take writer");
let stop_typing = Arc::new(AtomicBool::new(false));
let typer_stop = Arc::clone(&stop_typing);
let typer = thread::spawn(move || {
while !typer_stop.load(Ordering::Relaxed) {
if writer.write_all(typed).is_err() || writer.flush().is_err() {
break;
}
thread::sleep(Duration::from_millis(30));
}
});
let (tx, rx) = mpsc::channel::<Vec<u8>>();
thread::spawn(move || {
let mut acc = Vec::new();
let mut buf = [0u8; 256];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
acc.extend_from_slice(&buf[..n]);
if tx.send(acc.clone()).is_err() {
break;
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
let deadline = Instant::now() + Duration::from_secs(8);
let mut out = Vec::new();
loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(latest) => out = latest,
Err(mpsc::RecvTimeoutError::Timeout) => {
if Instant::now() >= deadline {
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
let _ = child.wait();
stop_typing.store(true, Ordering::Relaxed);
let _ = typer.join();
out
}
fn assert_spans_intact(out: &[u8]) {
let broken = count_subslice(out, b"mVlan11\x1b[39m91");
assert_eq!(
broken,
0,
"concurrent input split {broken} program-output token span(s); saw: {:?}",
String::from_utf8_lossy(out)
);
assert!(
contains_sgr_span(out, b"Vlan1191"),
"expected at least one intact Vlan1191 span; saw: {:?}",
String::from_utf8_lossy(out)
);
}
#[test]
fn split_program_output_token_survives_concurrent_nonechoing_input() {
assert_spans_intact(&echo_off_split_stream_while_typing(b"x"));
}
#[test]
fn split_program_output_token_survives_concurrent_input_matching_the_token() {
assert_spans_intact(&echo_off_split_stream_while_typing(b"Vlan11"));
}