348 lines
12 KiB
Rust
348 lines
12 KiB
Rust
use alacritty_terminal::{
|
|
grid::Dimensions,
|
|
index::{Column, Line},
|
|
};
|
|
use futures::StreamExt;
|
|
|
|
use super::*;
|
|
use crate::term::{
|
|
notify::Ask,
|
|
status::{Record, State},
|
|
};
|
|
|
|
struct Ran {
|
|
folders: Vec<PathBuf>,
|
|
marks: Vec<Mark>,
|
|
/// What programs said of their status, a full reset as none.
|
|
statuses: Vec<Option<Status>>,
|
|
/// Notifications and progress programs asked for.
|
|
asks: Vec<Ask>,
|
|
/// Titles, notifications and full resets, in the output's order.
|
|
said: Vec<String>,
|
|
text: String,
|
|
/// The first line's cells that carry a prompt's link.
|
|
prompt: Vec<usize>,
|
|
success: bool,
|
|
pid: u32,
|
|
columns: usize,
|
|
}
|
|
|
|
fn run(script: &str, size: (usize, usize)) -> Ran {
|
|
crate::testlog::init();
|
|
let launch = Launch {
|
|
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
|
cwd: std::env::temp_dir(),
|
|
env: vec![("ELY".into(), "ok".into())],
|
|
reports: false,
|
|
};
|
|
let (pty, mut events) =
|
|
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
|
let (folders, marks, statuses, asks, said, success) = futures::executor::block_on(async {
|
|
let (mut folders, mut marks, mut statuses, mut asks, mut said, mut success) = (
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
false,
|
|
);
|
|
while let Some(event) = events.next().await {
|
|
match event {
|
|
PtyEvent::Folder(folder) => folders.push(folder),
|
|
PtyEvent::Mark(mark) => marks.push(mark),
|
|
PtyEvent::Status(status) => statuses.push(Some(status)),
|
|
PtyEvent::Reset => {
|
|
said.push("reset".to_string());
|
|
statuses.push(None);
|
|
}
|
|
PtyEvent::Title(title) => said.push(format!("title {title}")),
|
|
PtyEvent::Ask(ask) => {
|
|
if let Ask::Show(notice) = &ask {
|
|
said.push(format!("show {}", notice.title));
|
|
}
|
|
asks.push(ask);
|
|
}
|
|
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
|
PtyEvent::Term(Event::Exit) => break,
|
|
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
|
}
|
|
}
|
|
(folders, marks, statuses, asks, said, success)
|
|
});
|
|
let term = pty.term.lock();
|
|
let row = &term.grid()[Line(0)];
|
|
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
|
let prompt = (0..24).filter(|&column| {
|
|
row[Column(column)]
|
|
.hyperlink()
|
|
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
|
|
});
|
|
Ran {
|
|
folders,
|
|
marks,
|
|
statuses,
|
|
asks,
|
|
said,
|
|
text: text.trim_end().to_string(),
|
|
prompt: prompt.collect(),
|
|
success,
|
|
pid: pty.pid,
|
|
columns: term.columns(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
|
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
|
assert!(ran.folders.is_empty());
|
|
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
|
assert!(ran.success);
|
|
}
|
|
|
|
#[test]
|
|
fn osc_7_reaches_the_session_as_a_folder() {
|
|
let ran = run(
|
|
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
|
(40, 4),
|
|
);
|
|
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
|
assert_eq!(ran.text, "x");
|
|
}
|
|
|
|
#[test]
|
|
fn osc_133_reaches_the_session_as_marks() {
|
|
let ran = run(
|
|
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
|
|
(40, 4),
|
|
);
|
|
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
|
|
assert_eq!(ran.marks, marks);
|
|
assert_eq!(ran.text, ">x");
|
|
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
|
|
}
|
|
|
|
#[test]
|
|
fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
|
|
let ran = run(
|
|
"printf '\\033]7501;state=working:progress=40\\033\\\\\\033]7501;?\\007\\033cx'",
|
|
(40, 4),
|
|
);
|
|
let working = Record {
|
|
state: State::Working,
|
|
kind: None,
|
|
progress: Some(40),
|
|
app: None,
|
|
title: None,
|
|
msg: None,
|
|
};
|
|
let said = [
|
|
Some(Status::Report(String::new(), working)),
|
|
Some(Status::Query),
|
|
None,
|
|
];
|
|
assert_eq!(ran.statuses, said);
|
|
assert_eq!(ran.text, "x");
|
|
}
|
|
|
|
#[test]
|
|
fn notifications_and_progress_reach_the_session_in_order() {
|
|
let ran = run(
|
|
"printf '\\033]9;Hello\\007\\033]777;notify;T;B\\007\\033]99;i=a:d=0;Hel\\033\\\\\\033]99;i=a;lo\\033\\\\\\033]9;4;1;40\\007x'",
|
|
(40, 4),
|
|
);
|
|
let titles: Vec<String> = ran
|
|
.asks
|
|
.iter()
|
|
.map(|ask| match ask {
|
|
Ask::Show(notice) => format!("{}/{}", notice.title, notice.body),
|
|
other => format!("{other:?}"),
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
titles,
|
|
["Hello/", "T/B", "Hello/", "Progress(Set(Some(40)))"]
|
|
);
|
|
assert_eq!(ran.text, "x");
|
|
}
|
|
|
|
#[test]
|
|
fn osc_99_gathers_a_full_encoded_chunk_and_the_lines_base64_carries() {
|
|
let ran = run(
|
|
"printf '\\033]99;i=m:d=0:e=1;'; yes YWFh | head -n 1024 | tr -d '\\n'; printf '\\033\\\\\\033]99;i=m:d=0:e=1;RU5E\\033\\\\\\033]99;i=m:p=body:e=1;TGluZSAxCkxpbmUgMg==\\007x'",
|
|
(40, 4),
|
|
);
|
|
let [Ask::Show(notice)] = ran.asks.as_slice() else {
|
|
panic!("one notification: {:?}", ran.asks);
|
|
};
|
|
assert_eq!(notice.title, format!("{}END", "a".repeat(3072)));
|
|
assert_eq!(notice.body, "Line 1\nLine 2");
|
|
assert_eq!(ran.text, "x");
|
|
}
|
|
|
|
#[test]
|
|
fn titles_reach_the_session_in_their_place_among_notifications() {
|
|
let long = "y".repeat(5000);
|
|
let many: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
|
|
let parts = [
|
|
"\\033]2;Old\\007".to_string(),
|
|
"\\033[22t".into(),
|
|
"\\033]2;New\\007".into(),
|
|
"\\033]9;a\\007".into(),
|
|
"\\033[23t".into(),
|
|
"\\033]9;b\\007".into(),
|
|
"\\033c".into(),
|
|
"\\033]9;c\\007".into(),
|
|
format!("\\033]2;{long}\\007"),
|
|
format!("\\033]2;{}\\007", many.join(";")),
|
|
"\\033]9;d\\007".into(),
|
|
];
|
|
let whole = run(&format!("printf '{}x'", parts.concat()), (40, 4));
|
|
let split: Vec<String> = parts
|
|
.iter()
|
|
.map(|part| format!("printf '{part}'; sleep 0.1"))
|
|
.collect();
|
|
let split = run(&format!("{}; printf x", split.join("; ")), (40, 4));
|
|
let said = [
|
|
"title Old".to_string(),
|
|
"title New".into(),
|
|
"show a".into(),
|
|
"title Old".into(),
|
|
"show b".into(),
|
|
"reset".into(),
|
|
"show c".into(),
|
|
format!("title {long}"),
|
|
format!("title {}", many[..15].join(";")),
|
|
"show d".into(),
|
|
];
|
|
assert_eq!(whole.said, said, "one write");
|
|
assert_eq!(split.said, said, "a write a sequence");
|
|
assert_eq!((whole.text.as_str(), split.text.as_str()), ("x", "x"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_escape_of_controls_keeps_the_pty_reading() {
|
|
let ran = run(
|
|
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
|
|
(40, 4),
|
|
);
|
|
assert_eq!(ran.text, "READY");
|
|
}
|
|
|
|
#[test]
|
|
fn the_foreground_is_the_program_s_own_group() {
|
|
let launch = Launch {
|
|
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
|
|
cwd: std::env::temp_dir(),
|
|
env: Vec::new(),
|
|
reports: false,
|
|
};
|
|
let (pty, _events) =
|
|
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
while pty.foreground().ok() != Some(pty.pid as i32) {
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"{:?}",
|
|
pty.foreground()
|
|
);
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
|
let ran = run("stty size", (9000, 8));
|
|
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
|
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
|
}
|
|
|
|
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
|
fn close_after_ready(script: &str, reaped: &str) {
|
|
crate::testlog::init();
|
|
let launch = Launch {
|
|
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
|
cwd: std::env::temp_dir(),
|
|
env: Vec::new(),
|
|
reports: false,
|
|
};
|
|
let (pty, mut events) =
|
|
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
|
futures::executor::block_on(async {
|
|
while let Some(event) = events.next().await {
|
|
let term = pty.term.lock();
|
|
let text: String = (0..5)
|
|
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
|
.collect();
|
|
drop(term);
|
|
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
let pid = pty.pid;
|
|
drop(pty);
|
|
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
while !crate::testlog::logged(&hung_up) {
|
|
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
|
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
|
assert!(!alive, "the program was reaped");
|
|
}
|
|
|
|
#[test]
|
|
fn closing_a_session_hangs_up_its_program() {
|
|
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
|
}
|
|
|
|
#[test]
|
|
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
|
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
|
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_folder_stops_the_start() {
|
|
let launch = Launch {
|
|
program: None,
|
|
cwd: "/nonexistent/ely".into(),
|
|
env: Vec::new(),
|
|
reports: false,
|
|
};
|
|
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
|
.err()
|
|
.expect("a missing folder is an error");
|
|
assert!(error.to_string().contains("/nonexistent/ely"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
|
std::fs::create_dir_all(&folder).expect("a temp folder");
|
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
|
let launch = Launch {
|
|
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
|
cwd: folder.clone(),
|
|
env: Vec::new(),
|
|
reports: false,
|
|
};
|
|
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
|
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
|
let error = started.err().expect("a folder it cannot enter is an error");
|
|
let error = error.to_string();
|
|
assert!(error.contains("Permission denied"), "{error}");
|
|
assert!(error.contains(&folder.display().to_string()), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
|
let size = fit(70_000, 24, (8, 16));
|
|
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
|
let size = fit(0, 0, (8, 16));
|
|
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
|
}
|