use std::time::Duration;
use termlens::{Error, Key, Terminal};
fn sh(script: &str) -> termlens::Result<Terminal> {
Terminal::builder()
.timeout(Duration::from_secs(10))
.args(["-c", script])
.spawn("/bin/sh")
}
#[test]
fn cursor_position_reports_the_position_at_the_query() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; printf 'abc\033[6n'; ",
r#"reply=$(head -c 6 | tr '\033' 'E'); "#,
r#"printf '\nunblocked:%s' "$reply"; read guard"#
))?;
t.wait_until(|s| s.contains("unblocked:E[1;4R"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn device_attribute_probes_are_unblocked() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; printf '\033[c'; ",
r#"reply=$(head -c 9 | tr '\033' 'E'); "#,
r#"printf 'unblocked:%s' "$reply"; read guard"#
))?;
t.wait_until(|s| s.contains("unblocked:E[?62;22c"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn background_color_query_gets_the_configured_answer() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.background_rgb(0x1e, 0x1e, 0x2e)
.args([
"-c",
concat!(
r"stty -icanon -echo; printf '\033]11;?\007'; ",
r#"reply=$(head -c 24 | tr '\033\007' 'EG'); "#,
r#"printf 'unblocked:%s' "$reply"; read guard"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("unblocked:E]11;rgb:1e1e/1e1e/2e2eG"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn foreground_color_query_gets_the_configured_answer() -> termlens::Result<()> {
let mut t = Terminal::builder()
.timeout(Duration::from_secs(10))
.foreground_rgb(0xcd, 0xd6, 0xf4)
.args([
"-c",
concat!(
r"stty -icanon -echo; printf '\033]10;?\007'; ",
r#"reply=$(head -c 24 | tr '\033\007' 'EG'); "#,
r#"printf 'unblocked:%s' "$reply"; read guard"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("unblocked:E]10;rgb:cdcd/d6d6/f4f4G"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn text_area_size_reports_the_real_grid() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; printf '\033[18t'; ",
r#"reply=$(head -c 10 | tr '\033' 'E'); "#,
r#"printf 'unblocked:%s' "$reply"; read guard"#
))?;
t.wait_until(|s| s.contains("unblocked:E[8;24;80t"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn unanswerable_queries_turn_timeouts_into_diagnoses() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(500))
.args(["-c", r"printf '\033[14t'; head -c 4 >/dev/null; echo never"])
.spawn("/bin/sh")
.unwrap();
let err = t.wait_until(|s| s.contains("never")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("^[[14t"), "query not named in: {msg}");
assert!(msg.contains("received no answer"), "no diagnosis in: {msg}");
}
#[test]
fn the_responder_can_be_disabled_and_says_what_went_unanswered() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(500))
.answer_queries(false)
.args(["-c", r"printf '\033[6n'; head -c 6 >/dev/null; echo never"])
.spawn("/bin/sh")
.unwrap();
let err = t.wait_until(|s| s.contains("never")).unwrap_err();
assert!(matches!(err, Error::Timeout { .. }));
let msg = err.to_string();
assert!(msg.contains("^[[6n"), "query not named in: {msg}");
}
#[test]
fn a_query_the_app_moved_past_is_context_not_a_cause() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(400))
.args([
"-c",
r"printf '\033[?u'; sleep 0.2; printf 'ready\n'; read guard",
])
.spawn("/bin/sh")
.unwrap();
t.wait_until(|s| s.contains("ready")).unwrap();
let err = t.wait_until(|s| s.contains("never-appears")).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("^[[?u"),
"the query is still worth naming: {msg}"
);
assert!(
!msg.contains("this is the cause"),
"the app moved past the probe — no causal claim belongs here: {msg}"
);
assert!(
msg.contains("produced output afterwards"),
"the note should say why it is only context: {msg}"
);
}
#[test]
fn all_unanswered_queries_are_named() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(400))
.args([
"-c",
r"printf '\033[?u\033[14t'; head -c 4 >/dev/null; echo never",
])
.spawn("/bin/sh")
.unwrap();
let err = t.wait_until(|s| s.contains("never")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("^[[?u"), "first query missing from: {msg}");
assert!(msg.contains("^[[14t"), "second query missing from: {msg}");
}
#[test]
fn wait_frame_timeouts_carry_the_query_note() {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(400))
.args(["-c", r"printf '\033[14t'; head -c 4 >/dev/null; echo never"])
.spawn("/bin/sh")
.unwrap();
let err = t.wait_frame(|s| s.contains("never")).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("^[[14t") && msg.contains("received no answer"),
"wait_frame withheld the diagnosis: {msg}"
);
}
#[test]
fn an_app_that_probes_for_synchronized_output_gets_it() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; printf '\033[?2026$p'; ",
r#"reply=$(head -c 10 | tr '\033' 'E'); "#,
r#"case "$reply" in *';0$y') printf 'unsupported'; read guard; exit 0 ;; esac; "#,
r"printf '\033[?2026h\033[HPROBED FRAME\033[?2026l'; read guard"
))?;
t.wait_frame(|s| s.contains("PROBED FRAME"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn mode_reports_are_truthful() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; printf '\033[?2004h'; ",
r"printf '\033[?2004$p\033[?1$p\033[?12$p'; ",
r#"reply=$(head -c 28 | tr '\033' 'E'); "#,
r#"printf 'got:%s' "$reply"; read guard"#
))?;
t.wait_until(|s| s.contains("got:"))?;
let row = t.screen().row_text(0);
assert!(row.contains("E[?2004;1$y"), "2004 should be set: {row}");
assert!(row.contains("E[?1;2$y"), "DECCKM should be reset: {row}");
assert!(row.contains("E[?12;0$y"), "12 is not tracked: {row}");
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn decrqss_and_palette_queries_are_named() {
for (label, script, shape) in [
(
"DECRQSS",
r#"printf '\033P$qm\033\\'; head -c 4 >/dev/null; echo never"#,
"^[P$qm",
),
(
"OSC 4",
r#"printf '\033]4;1;?\007'; head -c 4 >/dev/null; echo never"#,
"^[]4;1;?",
),
] {
let mut t = Terminal::builder()
.timeout(Duration::from_millis(400))
.args(["-c", script])
.spawn("/bin/sh")
.unwrap();
let err = t.wait_until(|s| s.contains("never")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(shape), "{label} not named in: {msg}");
}
}
#[test]
fn replies_are_not_echoed_into_the_screen() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; printf 'before\033[5n'; ",
r"head -c 4 >/dev/null; ",
r"printf ' after'; read guard"
))?;
t.wait_until(|s| s.contains("before after"))?;
assert!(
!t.screen().text().contains("[0n"),
"reply leaked into the grid:\n{}",
t.screen()
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn a_probe_then_enable_application_gets_its_mouse() -> termlens::Result<()> {
let mut t = sh(concat!(
r"stty -icanon -echo; ",
r#"printf '\033[?1000$p'; "#,
r#"reply=$(head -c 11 | tr '\033' 'E'); "#,
r#"case "$reply" in *';0$y') printf 'REFUSED:%s' "$reply"; read g; exit 0;; esac; "#,
r#"printf '\033[?1000h\033[?1006h'; printf 'MOUSE-ON:%s|' "$reply"; "#,
r#"click=$(head -c 20 | tr '\033' 'E'); printf 'CLICK:%s' "$click"; read g"#
))?;
t.wait_until(|s| s.contains("MOUSE-ON:E[?1000;2$y|"))?;
t.click(9, 4)?;
t.wait_until(|s| s.contains("CLICK:E[<0;10;5ME[<0;10;5m"))?;
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn decrqm_answers_for_focus_reporting() -> termlens::Result<()> {
for (script, expect, label) in [
(
r"printf '\033[?1004$p'",
";2$y",
"reset before the app enables it",
),
(
r"printf '\033[?1004h\033[?1004$p'",
";1$y",
"set after enabling",
),
(
r"printf '\033[?1004h\033[?1004l\033[?1004$p'",
";2$y",
"reset again after disabling",
),
] {
let mut t = Terminal::builder()
.size(80, 6)
.timeout(Duration::from_secs(10))
.args([
"-c",
&format!(
"stty -icanon -echo; {script}; \
head -c 11 | tr -d '\\033'; printf ' DONE'; read guard"
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("DONE"))?;
let row = t.screen().row_text(0);
assert!(row.contains(expect), "{label}: got {row:?}");
assert!(
!row.contains(";0$y"),
"{label}: must not report unrecognized"
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
}
Ok(())
}
#[test]
fn cell_size_answers_the_pixel_reports_and_the_ioctl() -> termlens::Result<()> {
let mut mute = Terminal::builder()
.size(80, 6)
.timeout(Duration::from_millis(700))
.args([
"-c",
r"stty -icanon -echo; printf '\033[16t'; printf MARK; read g",
])
.spawn("/bin/sh")?;
let err = mute
.wait_until(|s| s.contains("NEVER"))
.expect_err("must time out");
assert!(err.to_string().contains("^[[16t"), "named: {err}");
mute.send(Key::Enter)?;
let mut t = Terminal::builder()
.size(80, 24)
.cell_size(10, 20)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; ",
r"printf '\033[16t'; a=$(dd bs=1 count=32 2>/dev/null | tr -d '\033'); ",
r"printf '\033[14t'; b=$(dd bs=1 count=32 2>/dev/null | tr -d '\033'); ",
r#"printf 'cell[%s] win[%s] DONE' "$a" "$b"; read g"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("DONE"))?;
let row = t.screen().row_text(0);
assert!(row.contains("cell[[6;20;10t]"), "{row:?}");
assert!(row.contains("win[[4;480;800t]"), "{row:?}");
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn tiocgwinsz_agrees_with_the_declared_cell_size() -> termlens::Result<()> {
let read_winsize = r#"python3 -c 'import fcntl,struct,sys,termios; b=fcntl.ioctl(0,termios.TIOCGWINSZ,b"\0"*8); r,c,xp,yp=struct.unpack("HHHH",b); print(f"{c}x{r} px {xp}x{yp}", flush=True)'"#;
let script = format!("{read_winsize}; read a; {read_winsize}; printf DONE; read b");
let mut t = Terminal::builder()
.size(80, 24)
.cell_size(10, 20)
.timeout(Duration::from_secs(20))
.args(["-c", &script])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("px"))?;
assert!(
t.screen().contains("80x24 px 800x480"),
"the ioctl must carry the declared geometry:\n{}",
t.screen()
);
t.resize(40, 12)?;
t.send(Key::Enter)?;
t.wait_until(|s| s.contains("DONE"))?;
assert!(
t.screen().contains("40x12 px 400x240"),
"a resize must recompute it:\n{}",
t.screen()
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn declared_graphics_support_reaches_the_probe() -> termlens::Result<()> {
let mut plain = Terminal::builder()
.size(80, 6)
.timeout(Duration::from_secs(10))
.args([
"-c",
r"stty -icanon -echo; printf '\033[c'; head -c 9 | tr -d '\033'; printf ' DONE'; read g",
])
.spawn("/bin/sh")?;
plain.wait_until(|s| s.contains("DONE"))?;
let row = plain.screen().row_text(0);
assert!(
row.contains("[?62;22c"),
"nothing claimed by default: {row:?}"
);
assert!(!row.contains(";4;"), "no sixel by default: {row:?}");
plain.send(Key::Enter)?;
let mut sixel = Terminal::builder()
.size(80, 6)
.graphics(termlens::Graphics::Sixel)
.timeout(Duration::from_secs(10))
.args([
"-c",
r"stty -icanon -echo; printf '\033[c'; head -c 11 | tr -d '\033'; printf ' DONE'; read g",
])
.spawn("/bin/sh")?;
sixel.wait_until(|s| s.contains("DONE"))?;
assert!(
sixel.screen().row_text(0).contains("[?62;4;22c"),
"{:?}",
sixel.screen().row_text(0)
);
sixel.send(Key::Enter)?;
let mut kitty = Terminal::builder()
.size(80, 6)
.graphics(termlens::Graphics::Kitty)
.timeout(Duration::from_secs(10))
.args([
"-c",
r"stty -icanon -echo; printf '\033_Gi=7,a=q;\033\\'; head -c 9 | tr -d '\033'; printf ' DONE'; read g",
])
.spawn("/bin/sh")?;
kitty.wait_until(|s| s.contains("DONE"))?;
assert!(
kitty.screen().row_text(0).contains("_Gi=7;OK"),
"the reply echoes the id the probe named: {:?}",
kitty.screen().row_text(0)
);
kitty.send(Key::Enter)?;
Ok(())
}
#[test]
fn xtgettcap_answers_what_it_knows_and_declines_the_rest() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(120, 6)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; ",
r"printf '\033P+q544e;636f6c6f7273;7a7a7a7a\033\\'; ",
r"dd bs=1 count=200 2>/dev/null | tr -d '\033' | tr -s '\\' '|'; ",
r"printf ' DONE'; read g"
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("DONE"))?;
let text = t.screen().text();
assert!(
text.contains("P1+r544e=787465726d2d323536636f6c6f72"),
"TN must report the TERM the child was given:\n{text}"
);
assert!(
text.contains("P1+r636f6c6f7273=323536"),
"colors must be answered:\n{text}"
);
assert!(
text.contains("P0+r7a7a7a7a"),
"an unknown capability must be explicitly refused:\n{text}"
);
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn xtgettcap_tn_follows_the_configured_term() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(120, 6)
.env("TERM", "xterm")
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; ",
r"printf '\033P+q544e\033\\'; ",
r"dd bs=1 count=80 2>/dev/null | tr -d '\033' | tr -s '\\' '|'; ",
r#"printf ' term=%s DONE' "$TERM"; read g"#
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("DONE"))?;
let text = t.screen().text();
assert!(text.contains("P1+r544e=787465726d"), "{text}");
assert!(text.contains("term=xterm"), "{text}");
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}
#[test]
fn xtgettcap_key_capabilities_match_what_send_emits() -> termlens::Result<()> {
let mut t = Terminal::builder()
.size(120, 6)
.timeout(Duration::from_secs(10))
.args([
"-c",
concat!(
r"stty -icanon -echo min 0 time 20; ",
r"printf '\033P+q6b63757531\033\\'; ",
r"dd bs=1 count=80 2>/dev/null | tr -d '\033' | tr -s '\\' '|'; ",
r"printf ' DONE'; read g"
),
])
.spawn("/bin/sh")?;
t.wait_until(|s| s.contains("DONE"))?;
let text = t.screen().text();
assert!(text.contains("P1+r6b63757531=1b5b41"), "{text}");
assert_eq!(termlens::Key::Up.encode(), b"\x1b[A");
t.send(Key::Enter)?;
assert!(t.wait_exit()?.success());
Ok(())
}