diff --git a/Cargo.lock b/Cargo.lock index 758a27f..ee1878c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,10 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "emit" +version = "0.9.0" + [[package]] name = "encode_unicode" version = "1.0.0" diff --git a/crates/termlens/tests/backpressure.rs b/crates/termlens/tests/backpressure.rs index 7c94e12..c4c62e5 100644 --- a/crates/termlens/tests/backpressure.rs +++ b/crates/termlens/tests/backpressure.rs @@ -4,6 +4,10 @@ //! The other half of this story — what a harness can know about an //! application that never reads its replies — lives in `drain.rs`, next to //! the deadlock it must never cause. +//! +//! Stays on `/bin/sh` on purpose (#249): reading replies byte for byte +//! needs the terminal in raw mode, which the std-only `emit` fixture cannot +//! set — see the note in `drain.rs`. use std::time::Duration; diff --git a/crates/termlens/tests/basic.rs b/crates/termlens/tests/basic.rs index 64e3870..1610a79 100644 --- a/crates/termlens/tests/basic.rs +++ b/crates/termlens/tests/basic.rs @@ -1,10 +1,10 @@ -//! Integration tests against `/bin/sh`: spawning, waiting, environment -//! control, exit codes, and the failure modes (timeout / EOF). +//! Integration tests against the `emit` fixture: spawning, waiting, +//! environment control, exit codes, and the failure modes (timeout / EOF). //! //! These run headless — CI runners have no TTY, the harness makes its own. //! -//! Pattern note: every script that must *print something we assert on* -//! ends with a `read` guard, and we send Enter only after the assertion. +//! Pattern note: every program that must *print something we assert on* +//! ends with a `--wait`, and we send Enter only after the assertion. //! Output written immediately before exit can be discarded by macOS's PTY //! teardown (docs/DESIGN.md §2); keeping the child alive until the harness //! has seen the bytes makes these tests deterministic on every platform. @@ -16,15 +16,17 @@ use std::{ use termlens::{Error, Key, Terminal}; -const SH: &str = "/bin/sh"; +mod common; -fn sh(script: &str) -> termlens::Result { - Terminal::builder().args(["-c", script]).spawn(SH) +/// The `emit` fixture from the default builder; steps are documented in +/// `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::emit(steps) } #[test] fn echo_reaches_the_screen_and_child_exits_cleanly() -> termlens::Result<()> { - let mut t = sh("echo hello from a real PTY; read guard")?; + let mut t = emit(&["hello from a real PTY\n", "--wait"])?; t.wait_until(|s| s.contains("hello from a real PTY"))?; t.send(Key::Enter)?; let status = t.wait_exit()?; @@ -35,9 +37,9 @@ fn echo_reaches_the_screen_and_child_exits_cleanly() -> termlens::Result<()> { #[test] fn exit_codes_are_reported() -> termlens::Result<()> { - // The `read` keeps the exit from racing PTY setup; the line discipline - // buffers our Enter even if it lands before `read` starts. - let mut t = sh("read guard; exit 7")?; + // The `--wait` keeps the exit from racing PTY setup; the line discipline + // buffers our Enter even if it lands before the read starts. + let mut t = emit(&["--wait", "--exit", "7"])?; t.send(Key::Enter)?; let status = t.wait_exit()?; assert!(!status.success()); @@ -50,7 +52,11 @@ fn exit_codes_are_reported() -> termlens::Result<()> { #[test] fn signal_deaths_are_reported_as_signals_not_exit_codes() -> termlens::Result<()> { - let mut t = sh("read guard; kill -TERM $$")?; + // Stays on `/bin/sh`: a process that signals itself is what this test + // is about, and the std-only fixture has no `kill`. + let mut t = Terminal::builder() + .args(["-c", "read guard; kill -TERM $$"]) + .spawn("/bin/sh")?; t.send(Key::Enter)?; let status = t.wait_exit()?; assert!(!status.success()); @@ -65,10 +71,10 @@ fn signal_deaths_are_reported_as_signals_not_exit_codes() -> termlens::Result<() #[test] fn env_vars_reach_the_child() -> termlens::Result<()> { - let mut t = Terminal::builder() - .env("TERMTEST_MARKER", "42") - .args(["-c", r#"echo "marker=$TERMTEST_MARKER"; read guard"#]) - .spawn(SH)?; + let mut t = common::spawn_emit( + Terminal::builder().env("TERMTEST_MARKER", "42"), + &["marker=", "--env", "TERMTEST_MARKER", "--wait"], + )?; t.wait_until(|s| s.contains("marker=42"))?; t.send(Key::Enter)?; t.wait_exit()?; @@ -89,16 +95,17 @@ fn envs_accepts_common_pair_iterators() { #[test] fn envs_and_env_preserve_order_and_duplicates() -> termlens::Result<()> { - let mut t = Terminal::builder() - .env("VALUE", "env-first") - .envs([("VALUE", "envs-first"), ("SECOND", "two")]) - .env("VALUE", "env-last") - .envs([("THIRD", "three"), ("VALUE", "envs-last")]) - .args([ - "-c", - r#"echo "value=$VALUE second=$SECOND third=$THIRD"; read guard"#, - ]) - .spawn(SH)?; + let mut t = common::spawn_emit( + Terminal::builder() + .env("VALUE", "env-first") + .envs([("VALUE", "envs-first"), ("SECOND", "two")]) + .env("VALUE", "env-last") + .envs([("THIRD", "three"), ("VALUE", "envs-last")]), + &[ + "value=", "--env", "VALUE", " second=", "--env", "SECOND", " third=", "--env", "THIRD", + "--wait", + ], + )?; t.wait_until(|s| s.contains("value=envs-last second=two third=three"))?; t.send(Key::Enter)?; t.wait_exit()?; @@ -109,13 +116,13 @@ fn envs_and_env_preserve_order_and_duplicates() -> termlens::Result<()> { fn env_clear_hands_the_child_exactly_the_builder_environment() -> termlens::Result<()> { // Enumerate the whole environment rather than probing one name, so a // leaked variable cannot arrive unnoticed: SHELL did, filled in by the - // PTY layer from the host's login shell (#221). `/usr/bin/env` is spawned - // directly — no shell, which would add PWD and friends of its own — and - // by absolute path, because PATH is gone. - let mut t = Terminal::builder() - .env_clear() - .env("MARKER", "1") - .spawn("/usr/bin/env")?; + // PTY layer from the host's login shell (#221). The fixture prints its + // own environment — no shell, which would add PWD and friends of its own + // — and is spawned by absolute path, because PATH is gone. + let mut t = common::spawn_emit( + Terminal::builder().env_clear().env("MARKER", "1"), + &["--environ"], + )?; assert!(t.wait_exit()?.success()); let screen = t.screen(); let mut vars: Vec = screen @@ -143,15 +150,27 @@ fn env_clear_blocks_inheritance_but_keeps_explicit_vars_and_term() -> termlens:: std::env::var_os("HOME").is_some(), "test needs HOME in the parent env" ); - let mut t = Terminal::builder() - .envs([("KEPT_BEFORE", "yes")]) - .env_clear() - .envs([("KEPT_AFTER", "also")]) - .args([ - "-c", - r#"echo "home=${HOME:-unset} term=$TERM before=$KEPT_BEFORE after=$KEPT_AFTER"; read guard"#, - ]) - .spawn(SH)?; + let mut t = common::spawn_emit( + Terminal::builder() + .envs([("KEPT_BEFORE", "yes")]) + .env_clear() + .envs([("KEPT_AFTER", "also")]), + &[ + "home=", + "--env", + "HOME", + " term=", + "--env", + "TERM", + " before=", + "--env", + "KEPT_BEFORE", + " after=", + "--env", + "KEPT_AFTER", + "--wait", + ], + )?; t.wait_until(|s| s.contains("before=yes after=also"))?; let screen = t.screen(); assert!( @@ -169,10 +188,10 @@ fn env_clear_blocks_inheritance_but_keeps_explicit_vars_and_term() -> termlens:: #[test] fn explicit_term_overrides_the_default() -> termlens::Result<()> { - let mut t = Terminal::builder() - .env("TERM", "vt100") - .args(["-c", r#"echo "term=$TERM"; read guard"#]) - .spawn(SH)?; + let mut t = common::spawn_emit( + Terminal::builder().env("TERM", "vt100"), + &["term=", "--env", "TERM", "--wait"], + )?; t.wait_until(|s| s.contains("term=vt100"))?; t.send(Key::Enter)?; t.wait_exit()?; @@ -181,10 +200,13 @@ fn explicit_term_overrides_the_default() -> termlens::Result<()> { #[test] fn send_str_and_enter_round_trip_through_the_line_discipline() -> termlens::Result<()> { - let mut t = sh(r#"read line; echo "got: $line"; read guard"#)?; + // The line discipline echoes what is typed onto row 0 and moves to row + // 1; the fixture then writes the line it read, with a suffix the echo + // cannot have produced — so the wait proves the round trip, not the echo. + let mut t = emit(&["--echo-line", " back", "--wait"])?; t.send_str("hello")?; t.send(Key::Enter)?; - t.wait_until(|s| s.contains("got: hello"))?; + t.wait_until(|s| s.contains("hello back"))?; t.send(Key::Enter)?; assert!(t.wait_exit()?.success()); Ok(()) @@ -192,12 +214,12 @@ fn send_str_and_enter_round_trip_through_the_line_discipline() -> termlens::Resu #[test] fn timeout_error_embeds_the_screen_dump() { - let mut t = Terminal::builder() - .timeout(Duration::from_millis(400)) - .args(["-c", "echo something visible; exec cat"]) - .spawn(SH) - .unwrap(); - // `cat` keeps the terminal open forever; the predicate can never hold. + let mut t = common::spawn_emit( + Terminal::builder().timeout(Duration::from_millis(400)), + &["something visible\n", "--echo"], + ) + .unwrap(); + // `--echo` keeps the terminal open forever; the predicate can never hold. let err = t.wait_until(|s| s.contains("never printed")).unwrap_err(); let Error::Timeout { ref screen, .. } = err else { @@ -209,16 +231,16 @@ fn timeout_error_embeds_the_screen_dump() { assert!(msg.contains("timed out after 400ms"), "{msg}"); assert!(msg.contains("--- screen at timeout ---"), "{msg}"); assert!(msg.contains("something visible"), "{msg}"); - // Drop now kills the still-running `cat` — no zombies. + // Drop now kills the still-running child — no zombies. } #[test] fn waits_fail_fast_on_eof_instead_of_burning_the_timeout() { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(30)) - .args(["-c", "echo bye; read guard"]) - .spawn(SH) - .unwrap(); + let mut t = common::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(30)), + &["bye\n", "--wait"], + ) + .unwrap(); // Deterministic sequencing: observe the output, then let the child // exit, then wait for something that can never appear. t.wait_until(|s| s.contains("bye")).unwrap(); @@ -239,7 +261,7 @@ fn waits_fail_fast_on_eof_instead_of_burning_the_timeout() { #[test] fn wait_idle_resolves_in_output_gaps() -> termlens::Result<()> { - let mut t = sh("printf a; sleep 1.5; printf b; read guard")?; + let mut t = emit(&["a", "--sleep", "1.5s", "b", "--wait"])?; t.wait_until(|s| s.contains("a"))?; t.wait_idle(Duration::from_millis(200))?; diff --git a/crates/termlens/tests/builder_validation.rs b/crates/termlens/tests/builder_validation.rs index 9f5d38f..ab288f3 100644 --- a/crates/termlens/tests/builder_validation.rs +++ b/crates/termlens/tests/builder_validation.rs @@ -4,7 +4,16 @@ use std::time::Duration; -use termlens::{Error, Terminal}; +use termlens::{Error, Terminal, TerminalBuilder}; + +mod common; + +/// The `emit` fixture from a builder the test has configured — most of +/// these never get as far as spawning it. Steps are documented in +/// `fixtures/emit/src/main.rs`. +fn emit(builder: TerminalBuilder, steps: &[&str]) -> termlens::Result { + common::spawn_emit(builder, steps) +} #[test] fn an_empty_program_name_is_a_short_typed_error() { @@ -33,7 +42,7 @@ fn the_default_working_directory_is_the_test_process_s() -> termlens::Result<()> // Without `current_dir` the child used to start in $HOME — the PTY // layer's fallback — while `current_dir`'s rustdoc said it inherited the // test runner's (#215). Pinned here so the default cannot move quietly. - let mut t = Terminal::builder().spawn("/bin/pwd")?; + let mut t = emit(Terminal::builder(), &["--cwd"])?; assert!(t.wait_exit()?.success()); // The path is read off the grid, and a path longer than the 80 columns // wraps onto the next row — a checkout under a long temporary directory @@ -56,24 +65,23 @@ fn the_default_working_directory_is_the_test_process_s() -> termlens::Result<()> fn a_bare_program_name_under_env_clear_is_refused_with_the_remedies() { // env_clear drops PATH, and the PTY layer's only diagnostic for the bare // name that then cannot resolve was "Unable to resolve the PATH" (#222). - let err = Terminal::builder().env_clear().spawn("sh").unwrap_err(); + // The fixture stands in for any program: bare, it is refused before + // anything tries to find it. + let bin = common::fixture_bin("emit"); + let err = Terminal::builder().env_clear().spawn("emit").unwrap_err(); assert!(matches!(err, Error::Spawn { .. }), "{err}"); let msg = err.to_string(); - for needed in ["`sh`", "env_clear", "PATH", "absolute path"] { + for needed in ["`emit`", "env_clear", "PATH", "absolute path"] { assert!(msg.contains(needed), "missing {needed:?} in: {msg}"); } // Either remedy works: a PATH of the test's own… let with_path = Terminal::builder() .env_clear() - .env("PATH", "/usr/bin:/bin") - .args(["-c", "true"]) - .spawn("sh"); + .env("PATH", bin.parent().expect("the fixture has a directory")) + .spawn("emit"); assert!(with_path.is_ok(), "{:?}", with_path.err()); // …or an absolute path, which never needed one. - let absolute = Terminal::builder() - .env_clear() - .args(["-c", "true"]) - .spawn("/bin/sh"); + let absolute = Terminal::builder().env_clear().spawn(&bin); assert!(absolute.is_ok(), "{:?}", absolute.err()); } @@ -82,12 +90,13 @@ fn a_missing_working_directory_fails_instead_of_running_elsewhere() { let missing = std::env::temp_dir().join("termlens-no-such-dir-xyz"); assert!(!missing.is_dir(), "test precondition"); - let err = Terminal::builder() - .timeout(Duration::from_secs(2)) - .current_dir(&missing) - .args(["-c", "pwd"]) - .spawn("/bin/sh") - .expect_err("the requested directory does not exist"); + let err = emit( + Terminal::builder() + .timeout(Duration::from_secs(2)) + .current_dir(&missing), + &["--cwd"], + ) + .expect_err("the requested directory does not exist"); assert!(matches!(err, Error::Spawn { .. }), "got: {err}"); let message = err.to_string(); @@ -104,12 +113,13 @@ fn a_file_is_not_a_working_directory() { let file = std::env::temp_dir().join("termlens-cwd-probe-file"); std::fs::write(&file, b"x").expect("write probe file"); - let err = Terminal::builder() - .timeout(Duration::from_secs(2)) - .current_dir(&file) - .args(["-c", "pwd"]) - .spawn("/bin/sh") - .expect_err("a file is not a directory"); + let err = emit( + Terminal::builder() + .timeout(Duration::from_secs(2)) + .current_dir(&file), + &["--cwd"], + ) + .expect_err("a file is not a directory"); assert!(matches!(err, Error::Spawn { .. }), "got: {err}"); let _ = std::fs::remove_file(&file); @@ -123,10 +133,7 @@ fn a_file_is_not_a_working_directory() { #[test] fn a_single_row_or_column_is_refused_like_a_zero() { for (cols, rows) in [(1u16, 8u16), (80, 1), (1, 1), (2, 1), (1, 2)] { - let err = Terminal::builder() - .size(cols, rows) - .args(["-c", "true"]) - .spawn("/bin/sh") + let err = emit(Terminal::builder().size(cols, rows), &[]) .expect_err(&format!("{cols}x{rows} must be refused")); assert!(matches!(err, Error::Size(_)), "{cols}x{rows}: got {err}"); assert!( @@ -142,22 +149,24 @@ fn a_single_row_or_column_is_refused_like_a_zero() { #[test] fn the_narrowest_allowed_terminal_renders_both_trigger_shapes() -> termlens::Result<()> { // Trigger A was one column meeting a double-width character. - let mut wide = Terminal::builder() - .size(2, 8) - .timeout(Duration::from_secs(5)) - .args(["-c", r"printf '\346\261\211'; read guard"]) - .spawn("/bin/sh")?; + let mut wide = emit( + Terminal::builder() + .size(2, 8) + .timeout(Duration::from_secs(5)), + &["汉", "--wait"], + )?; wide.wait_until(|s| s.contains("汉"))?; assert_eq!(wide.screen().row_text(0).trim_end(), "汉"); wide.send(termlens::Key::Enter)?; wide.wait_exit()?; // Trigger B was one row meeting a line that wraps. - let mut wrap = Terminal::builder() - .size(2, 2) - .timeout(Duration::from_secs(5)) - .args(["-c", r"printf 'abcZ'; read guard"]) - .spawn("/bin/sh")?; + let mut wrap = emit( + Terminal::builder() + .size(2, 2) + .timeout(Duration::from_secs(5)), + &["abcZ", "--wait"], + )?; wrap.wait_until(|s| s.contains("Z"))?; let screen = wrap.screen(); assert_eq!(screen.row_text(0).trim_end(), "ab", "{screen}"); @@ -173,12 +182,13 @@ fn the_narrowest_allowed_terminal_renders_both_trigger_shapes() -> termlens::Res #[test] fn a_zero_dimension_is_rejected_before_it_reaches_the_emulator() { for (cols, rows) in [(0u16, 24u16), (80, 0), (0, 0)] { - let err = Terminal::builder() - .size(cols, rows) - .timeout(Duration::from_secs(2)) - .args(["-c", "read x"]) - .spawn("/bin/sh") - .expect_err("a terminal cannot have a zero dimension"); + let err = emit( + Terminal::builder() + .size(cols, rows) + .timeout(Duration::from_secs(2)), + &["--wait"], + ) + .expect_err("a terminal cannot have a zero dimension"); assert!(matches!(err, Error::Size(_)), "{cols}x{rows}: got {err}"); assert!( err.to_string().contains(&format!("{cols}x{rows}")), @@ -189,11 +199,12 @@ fn a_zero_dimension_is_rejected_before_it_reaches_the_emulator() { #[test] fn resize_to_zero_is_refused_without_touching_the_pty_or_the_grid() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(80, 24) - .timeout(Duration::from_secs(5)) - .args(["-c", "printf ready; read x"]) - .spawn("/bin/sh")?; + let mut t = emit( + Terminal::builder() + .size(80, 24) + .timeout(Duration::from_secs(5)), + &["ready", "--wait"], + )?; t.wait_until(|s| s.contains("ready"))?; // Zero, and — the value the guard used to let through — one, on each @@ -223,11 +234,12 @@ fn resize_to_zero_is_refused_without_touching_the_pty_or_the_grid() -> termlens: /// `send` uses — EOF, so nothing is left to receive the SIGWINCH. #[test] fn resize_after_the_child_exits_is_refused_like_send() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(20, 4) - .timeout(Duration::from_secs(10)) - .args(["-c", "printf ready; read _"]) - .spawn("/bin/sh")?; + let mut t = emit( + Terminal::builder() + .size(20, 4) + .timeout(Duration::from_secs(10)), + &["ready", "--wait"], + )?; t.wait_until(|s| s.contains("ready"))?; t.send(termlens::Key::Enter)?; assert!(t.wait_exit()?.success()); @@ -281,10 +293,7 @@ fn a_missing_program_still_reports_the_underlying_search_failure() { #[test] fn an_implausible_size_is_refused_with_the_limit_named() { for (cols, rows) in [(5000, 5000), (1001, 24), (80, 1001), (u16::MAX, u16::MAX)] { - let err = Terminal::builder() - .size(cols, rows) - .args(["-c", "true"]) - .spawn("/bin/sh") + let err = emit(Terminal::builder().size(cols, rows), &[]) .expect_err("a terminal this large is refused"); assert!(matches!(err, Error::Size(_)), "{cols}x{rows}: got {err}"); let msg = err.to_string(); @@ -303,11 +312,12 @@ fn an_implausible_size_is_refused_with_the_limit_named() { /// `resize` is where a computed dimension is most likely to go wrong. #[test] fn the_limit_is_inclusive_and_resize_honours_it() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(1000, 1000) - .timeout(Duration::from_secs(20)) - .args(["-c", "printf READY; read guard"]) - .spawn("/bin/sh")?; + let mut t = emit( + Terminal::builder() + .size(1000, 1000) + .timeout(Duration::from_secs(20)), + &["READY", "--wait"], + )?; t.wait_until(|s| s.contains("READY"))?; assert_eq!(t.screen().size(), (1000, 1000)); diff --git a/crates/termlens/tests/charset.rs b/crates/termlens/tests/charset.rs index 4d70dad..20d19a6 100644 --- a/crates/termlens/tests/charset.rs +++ b/crates/termlens/tests/charset.rs @@ -9,23 +9,33 @@ use std::time::Duration; use termlens::{Color, Key, Terminal}; -fn sh(script: &str) -> termlens::Result { - Terminal::builder() - .size(40, 6) - .timeout(Duration::from_secs(10)) - .args(["-c", script]) - .spawn("/bin/sh") +mod common; + +/// The `emit` fixture on a 40x6 terminal. One `--raw` per line the test +/// draws, with `\e` for ESC, `\x0e`/`\x0f` for SO/SI; steps are documented +/// in `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit( + Terminal::builder() + .size(40, 6) + .timeout(Duration::from_secs(10)), + steps, + ) } /// The reproduction from the issue, as a whole frame. #[test] fn an_ncurses_style_border_reads_as_box_drawing() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033(0lqqqk\033(B\n'; ", - r"printf '\033(0x\033(B in \033(0x\033(B\n'; ", - r"printf '\033(0mqqqj\033(B\n'; ", - "printf DONE; read _" - ))?; + let mut t = emit(&[ + "--raw", + r"\e(0lqqqk\e(B\n", + "--raw", + r"\e(0x\e(B in \e(0x\e(B\n", + "--raw", + r"\e(0mqqqj\e(B\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "┌───┐", "{s}"); @@ -46,7 +56,7 @@ fn an_ncurses_style_border_reads_as_box_drawing() -> termlens::Result<()> { /// `smacs`/`rmacs` are the locking shifts SO and SI. #[test] fn shift_out_and_shift_in_select_the_designated_set() -> termlens::Result<()> { - let mut t = sh(r"printf '\033)0\016lqk\017 lqk \016x\017'; printf DONE; read _")?; + let mut t = emit(&["--raw", r"\e)0\x0elqk\x0f lqk \x0ex\x0f", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "┌─┐ lqk │DONE", "{s}"); @@ -61,7 +71,7 @@ fn shift_out_and_shift_in_select_the_designated_set() -> termlens::Result<()> { /// grids stayed the same shape through the rewrite. #[test] fn a_styled_border_keeps_its_style() -> termlens::Result<()> { - let mut t = sh(r"printf '\033(0\033[31mqqq\033[0m\033(B end'; printf DONE; read _")?; + let mut t = emit(&["--raw", r"\e(0\e[31mqqq\e[0m\e(B end", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "─── endDONE", "{s}"); @@ -87,7 +97,7 @@ fn a_styled_border_keeps_its_style() -> termlens::Result<()> { /// must not resurrect a designation from before it (#232). #[test] fn a_hard_reset_returns_to_ascii() -> termlens::Result<()> { - let mut t = sh(r"printf '\033(0q\0337\033c\0338q'; printf DONE; read _")?; + let mut t = emit(&["--raw", r"\e(0q\e7\ec\e8q", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "qDONE", "{s}"); @@ -103,11 +113,14 @@ fn a_hard_reset_returns_to_ascii() -> termlens::Result<()> { /// an application's teardown reset kept rendering as box drawing (#233). #[test] fn a_soft_reset_returns_to_ascii_without_clearing_the_screen() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033(0q\033[!pq\n'; ", - r"printf '\033(0\0337\033[!p\0338lqk\n'; ", - "printf DONE; read _" - ))?; + let mut t = emit(&[ + "--raw", + r"\e(0q\e[!pq\n", + "--raw", + r"\e(0\e7\e[!p\e8lqk\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "─q", "{s}"); @@ -127,11 +140,14 @@ fn a_soft_reset_returns_to_ascii_without_clearing_the_screen() -> termlens::Resu /// the shift is part of what is saved. #[test] fn decsc_and_decrc_save_and_restore_the_charset_state() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033(0\0337\033(B\0338lqk\033(B\n'; ", - r"printf '\033)0\016\0337\017\0338lqk\017\n'; ", - "printf DONE; read _" - ))?; + let mut t = emit(&[ + "--raw", + r"\e(0\e7\e(B\e8lqk\e(B\n", + "--raw", + r"\e)0\x0e\e7\x0f\e8lqk\x0f\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "┌─┐", "{s}"); @@ -145,7 +161,7 @@ fn decsc_and_decrc_save_and_restore_the_charset_state() -> termlens::Result<()> /// ASCII — rather than leaving whatever was last designated. #[test] fn decrc_with_nothing_saved_returns_to_ascii() -> termlens::Result<()> { - let mut t = sh(r"printf '\033(0\0338lqk'; printf DONE; read _")?; + let mut t = emit(&["--raw", r"\e(0\e8lqk", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "lqkDONE", "{s}"); @@ -159,11 +175,14 @@ fn decrc_with_nothing_saved_returns_to_ascii() -> termlens::Result<()> { /// translate it, which is worse than never shifting. #[test] fn ss2_and_ss3_invoke_g2_g3_for_one_character() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033*0\033Nl\033(B|\n'; ", - r"printf '\033+0\033Ol\033(B|\n'; ", - "printf DONE; read _" - ))?; + let mut t = emit(&[ + "--raw", + r"\e*0\eNl\e(B|\n", + "--raw", + r"\e+0\eOl\e(B|\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "┌|", "{s}"); @@ -180,7 +199,7 @@ fn ss2_and_ss3_invoke_g2_g3_for_one_character() -> termlens::Result<()> { /// A single shift overrides SO for one character without leaving G1. #[test] fn a_single_shift_overrides_so_for_one_character() -> termlens::Result<()> { - let mut t = sh(r"printf '\033)0\033*B\016\033Nlqk\017'; printf DONE; read _")?; + let mut t = emit(&["--raw", r"\e)0\e*B\x0e\eNlqk\x0f", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "l─┐DONE", "{s}"); @@ -193,7 +212,7 @@ fn a_single_shift_overrides_so_for_one_character() -> termlens::Result<()> { /// returns G2 to ASCII, so redesignating without a new shift stays a letter. #[test] fn a_pending_single_shift_does_not_survive_ris() -> termlens::Result<()> { - let mut t = sh(r"printf '\033*0\033N\033c\033*0l'; printf DONE; read _")?; + let mut t = emit(&["--raw", r"\e*0\eN\ec\e*0l", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "lDONE", "{s}"); @@ -208,12 +227,16 @@ fn a_pending_single_shift_does_not_survive_ris() -> termlens::Result<()> { /// consume left the shift pending and turned that `l` into `┌`. #[test] fn a_multibyte_character_consumes_a_single_shift() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033*0\033N汉l\n'; ", - r"printf '\033*0\033N🦀l\n'; ", - r"printf '\033*0\033Nél\n'; ", - "printf DONE; read _" - ))?; + let mut t = emit(&[ + "--raw", + r"\e*0\eN汉l\n", + "--raw", + r"\e*0\eN🦀l\n", + "--raw", + r"\e*0\eNél\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "汉l", "{s}"); @@ -235,11 +258,14 @@ fn a_multibyte_character_consumes_a_single_shift() -> termlens::Result<()> { /// way they select the graphics set. #[test] fn the_uk_set_draws_a_pound_sign_at_hash() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033(A#42 a-z\033(B#\n'; ", - r"printf '\033)A\016#\017#\n'; ", - "printf DONE; read _" - ))?; + let mut t = emit(&[ + "--raw", + r"\e(A#42 a-z\e(B#\n", + "--raw", + r"\e)A\x0e#\x0f#\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "£42 a-z#", "{s}"); diff --git a/crates/termlens/tests/common/mod.rs b/crates/termlens/tests/common/mod.rs index 3646572..5f95170 100644 --- a/crates/termlens/tests/common/mod.rs +++ b/crates/termlens/tests/common/mod.rs @@ -4,6 +4,26 @@ use std::path::PathBuf; use std::process::Command; use std::sync::Mutex; +use termlens::{Terminal, TerminalBuilder}; + +/// Spawn the `emit` fixture with `steps` from a builder the caller has +/// already sized and timed: what `sh -c 'printf …; read _'` used to be, +/// with no shell deciding how `printf` reads an escape (#249). The steps +/// are documented in `fixtures/emit/src/main.rs`. +/// +/// This module is compiled into every test binary that declares it, and +/// not every one of them uses every helper. +#[allow(dead_code)] +pub(crate) fn spawn_emit(builder: TerminalBuilder, steps: &[&str]) -> termlens::Result { + builder.args(steps).spawn(fixture_bin("emit")) +} + +/// [`spawn_emit`] from the default builder. +#[allow(dead_code)] +pub(crate) fn emit(steps: &[&str]) -> termlens::Result { + spawn_emit(Terminal::builder(), steps) +} + /// Fixture names already rebuilt by this test process. static BUILT: Mutex> = Mutex::new(Vec::new()); diff --git a/crates/termlens/tests/concurrency.rs b/crates/termlens/tests/concurrency.rs index 6ac6b9c..37f595a 100644 --- a/crates/termlens/tests/concurrency.rs +++ b/crates/termlens/tests/concurrency.rs @@ -17,6 +17,8 @@ use std::time::Duration; use termlens::{Key, Terminal}; +mod common; + /// More than any runner has cores, and more than the default `--test-threads` /// on the largest machine anyone is likely to be sitting at. const AT_ONCE: usize = 24; @@ -30,16 +32,16 @@ fn two_dozen_terminals_open_at_once() { let report = report.clone(); threads.push(thread::spawn(move || { let outcome = (|| -> termlens::Result<()> { - let mut terminal = Terminal::builder() - .size(40, 10) - .env_clear() - .timeout(Duration::from_secs(30)) - .arg("-c") - // The `read` is the instant-exit guard: a child that + let mut terminal = common::spawn_emit( + Terminal::builder() + .size(40, 10) + .env_clear() + .timeout(Duration::from_secs(30)), + // The `--wait` is the instant-exit guard: a child that // writes and dies inside a millisecond can lose its // output to the PTY teardown. - .arg(format!("printf 'terminal {index}\\n'; read _")) - .spawn("/bin/sh")?; + &[&format!("terminal {index}\n"), "--wait"], + )?; terminal.wait_until(|screen| screen.contains(&format!("terminal {index}")))?; terminal.send(Key::Enter)?; terminal.wait_exit()?; @@ -80,14 +82,14 @@ fn terminals_recycle_without_running_out_of_devices() { for round in 0..8 { let mut open = Vec::new(); for index in 0..6 { - let mut terminal = Terminal::builder() - .size(20, 5) - .env_clear() - .timeout(Duration::from_secs(30)) - .arg("-c") - .arg(format!("printf 'round {round} {index}\\n'; read _")) - .spawn("/bin/sh") - .unwrap_or_else(|error| panic!("round {round}, terminal {index}: {error}")); + let mut terminal = common::spawn_emit( + Terminal::builder() + .size(20, 5) + .env_clear() + .timeout(Duration::from_secs(30)), + &[&format!("round {round} {index}\n"), "--wait"], + ) + .unwrap_or_else(|error| panic!("round {round}, terminal {index}: {error}")); terminal .wait_until(|screen| screen.contains(&format!("round {round} {index}"))) .unwrap_or_else(|error| panic!("round {round}, terminal {index}: {error}")); diff --git a/crates/termlens/tests/drain.rs b/crates/termlens/tests/drain.rs index cc9a065..f463dfc 100644 --- a/crates/termlens/tests/drain.rs +++ b/crates/termlens/tests/drain.rs @@ -6,6 +6,11 @@ //! can proceed — a permanent hang with no test input involved. This is //! the one failure the harness must never produce, since a hung harness //! cannot report anything at all. +//! +//! These stay on `/bin/sh` on purpose (#249): every program here puts the +//! terminal in raw mode with `stty -icanon -echo` so replies are neither +//! echoed onto the grid nor held for a newline, and the std-only `emit` +//! fixture has no way to set a terminal mode. use std::time::{Duration, Instant}; diff --git a/crates/termlens/tests/fixtures.rs b/crates/termlens/tests/fixtures.rs index e5b8f04..353b4df 100644 --- a/crates/termlens/tests/fixtures.rs +++ b/crates/termlens/tests/fixtures.rs @@ -129,11 +129,12 @@ fn a_needle_finds_text_in_the_other_normalization_form() -> termlens::Result<()> (nfd, nfc, "NFD screen, NFC needle"), (nfc, nfd, "NFC screen, NFD needle"), ] { - let mut t = Terminal::builder() - .size(40, 4) - .timeout(Duration::from_secs(10)) - .args(["-c", &format!("printf '{on_screen} MARK'; read guard")]) - .spawn("/bin/sh")?; + let mut t = util::spawn_emit( + Terminal::builder() + .size(40, 4) + .timeout(Duration::from_secs(10)), + &[&format!("{on_screen} MARK"), "--wait"], + )?; t.wait_until(|s| s.contains("MARK"))?; let s = t.screen(); diff --git a/crates/termlens/tests/frames.rs b/crates/termlens/tests/frames.rs index ffd51b6..4b686f3 100644 --- a/crates/termlens/tests/frames.rs +++ b/crates/termlens/tests/frames.rs @@ -9,6 +9,17 @@ use termlens::{Error, FrameTiming, Key, Terminal}; mod common; use common as util; +/// The `emit` fixture from a builder the test has timed; steps are +/// documented in `fixtures/emit/src/main.rs`. A burst that must arrive as +/// one read is one `--raw`. +fn emit(builder: termlens::TerminalBuilder, steps: &[&str]) -> termlens::Result { + util::spawn_emit(builder, steps) +} + +/// Three complete frames as one write: `STEP 1`, `STEP 2`, `STEP 3`. +const BURST_OF_THREE: &str = + r"\e[?2026h\e[HSTEP 1\e[?2026l\e[?2026h\e[HSTEP 2\e[?2026l\e[?2026h\e[HSTEP 3\e[?2026l"; + fn spawn_form_echo() -> termlens::Result { Terminal::builder() .size(80, 24) @@ -105,16 +116,18 @@ fn apps_without_synchronized_output_time_out_with_guidance() { /// arbitrarily old. #[test] fn wait_frame_timeouts_embed_the_live_screen_not_the_last_frame() { - let mut t = Terminal::builder() - .timeout(Duration::from_millis(400)) - .args([ - "-c", - // One synchronized frame, then unbracketed output that will - // never complete a frame. - r"printf '\033[?2026h\033[HOLD FRAME\033[?2026l'; printf '\r\nLIVE SCREEN'; read quit", - ]) - .spawn("sh") - .unwrap(); + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(400)), + // One synchronized frame, then unbracketed output that will + // never complete a frame. + &[ + "--raw", + r"\e[?2026h\e[HOLD FRAME\e[?2026l", + "\r\nLIVE SCREEN", + "--wait", + ], + ) + .unwrap(); t.wait_frame(|s| s.contains("OLD FRAME")).unwrap(); t.wait_until(|s| s.contains("LIVE SCREEN")).unwrap(); @@ -139,18 +152,11 @@ fn wait_frame_timeouts_embed_the_live_screen_not_the_last_frame() { /// at 3. #[test] fn every_frame_of_a_burst_is_observable_in_order() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - // Three complete frames in ONE write, then park. - concat!( - r"printf '\033[?2026h\033[HSTEP 1\033[?2026l", - r"\033[?2026h\033[HSTEP 2\033[?2026l", - r"\033[?2026h\033[HSTEP 3\033[?2026l'; read guard" - ), - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder().timeout(Duration::from_secs(10)), + // Three complete frames in ONE write, then park. + &["--raw", BURST_OF_THREE, "--wait"], + )?; // Settle on the live screen first, so all three frames have certainly // arrived (and been coalesced into as few reads as the OS chose) @@ -174,14 +180,14 @@ fn every_frame_of_a_burst_is_observable_in_order() -> termlens::Result<()> { #[test] fn a_burst_longer_than_the_retention_bound_drops_its_oldest_frames() -> termlens::Result<()> { // 12 frames in one write, against a retention bound of 8. - let mut script = String::new(); + let mut burst = String::new(); for n in 1..=12 { - script.push_str(&format!(r"\033[?2026h\033[HFRAME {n:02}\033[?2026l")); + burst.push_str(&format!(r"\e[?2026h\e[HFRAME {n:02}\e[?2026l")); } - let mut t = Terminal::builder() - .timeout(Duration::from_millis(600)) - .args(["-c", &format!("printf '{script}'; read guard")]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(600)), + &["--raw", &burst, "--wait"], + )?; t.wait_until(|s| s.contains("FRAME 12"))?; // The most recent 8 are retained: 05..=12, so 05 is the oldest that @@ -198,11 +204,11 @@ fn a_burst_longer_than_the_retention_bound_drops_its_oldest_frames() -> termlens #[test] fn wait_frame_fails_fast_on_eof() { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(30)) - .args(["-c", r"printf '\033[?2026hdone\033[?2026l'; read guard"]) - .spawn("sh") - .unwrap(); + let mut t = emit( + Terminal::builder().timeout(Duration::from_secs(30)), + &["--raw", r"\e[?2026hdone\e[?2026l", "--wait"], + ) + .unwrap(); t.wait_frame(|s| s.contains("done")).unwrap(); t.send(Key::Enter).unwrap(); @@ -217,12 +223,12 @@ fn wait_frame_fails_fast_on_eof() { #[test] fn wait_idle_does_not_resolve_inside_an_open_synchronized_update() { - // The frame never ends: BSU, content, then the app parks on `read`. - let mut t = Terminal::builder() - .timeout(Duration::from_millis(600)) - .args(["-c", r"printf '\033[?2026hhalf a frame'; read guard"]) - .spawn("sh") - .unwrap(); + // The frame never ends: BSU, content, then the app parks on `--wait`. + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(600)), + &["--raw", r"\e[?2026hhalf a frame", "--wait"], + ) + .unwrap(); t.wait_until(|s| s.contains("half a frame")).unwrap(); let err = t.wait_idle(Duration::from_millis(100)).unwrap_err(); @@ -238,14 +244,11 @@ fn an_unmatched_end_publishes_no_frame() { // `?2026l` with no Begin must not manufacture a frame out of whatever // is on the grid — and must leave the frame count at zero, since that // is what gates the diagnosis below. - let mut t = Terminal::builder() - .timeout(Duration::from_millis(600)) - .args([ - "-c", - r"printf '\033[2J\033[HNO-BEGIN\033[?2026l'; read guard", - ]) - .spawn("sh") - .unwrap(); + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(600)), + &["--raw", r"\e[2J\e[HNO-BEGIN\e[?2026l", "--wait"], + ) + .unwrap(); t.wait_until(|s| s.contains("NO-BEGIN")).unwrap(); let err = t.wait_frame(|s| s.contains("NO-BEGIN")).unwrap_err(); @@ -261,18 +264,17 @@ fn a_defensive_mode_reset_keeps_the_never_emitted_diagnosis() { // defensively, and such a string contains `?2026l`. One stray End used // to replace the pointed diagnosis with a frame count, which reads as // "the app is frame-capable, your predicate is wrong". - let mut t = Terminal::builder() - .timeout(Duration::from_millis(600)) - .args([ - "-c", - concat!( - r"printf '\033[?2026l\033[?25h\033[?1000l\033[?1002l", - r"\033[?1003l\033[?2004l\033[?1049l'; ", - r"printf '\033[2J\033[HPLAIN-PAINT'; read guard" - ), - ]) - .spawn("sh") - .unwrap(); + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(600)), + &[ + "--raw", + r"\e[?2026l\e[?25h\e[?1000l\e[?1002l\e[?1003l\e[?2004l\e[?1049l", + "--raw", + r"\e[2J\e[HPLAIN-PAINT", + "--wait", + ], + ) + .unwrap(); t.wait_until(|s| s.contains("PLAIN-PAINT")).unwrap(); let err = t.wait_frame(|s| s.contains("NEVER-DRAWN")).unwrap_err(); @@ -293,14 +295,17 @@ fn a_begin_end_pair_that_drew_nothing_is_still_a_frame() { // application that opens and closes a synchronized update completed a // repaint, even if the result is identical — deciding otherwise would // mean diffing grids and calling a genuine no-op repaint a non-event. - let mut t = Terminal::builder() - .timeout(Duration::from_secs(5)) - .args([ - "-c", - r"printf '\033[2J\033[HSTATIC'; printf '\033[?2026h\033[?2026l'; read guard", - ]) - .spawn("sh") - .unwrap(); + let mut t = emit( + Terminal::builder().timeout(Duration::from_secs(5)), + &[ + "--raw", + r"\e[2J\e[HSTATIC", + "--raw", + r"\e[?2026h\e[?2026l", + "--wait", + ], + ) + .unwrap(); t.wait_frame(|s| s.contains("STATIC")).unwrap(); t.send(Key::Enter).unwrap(); } @@ -310,16 +315,17 @@ fn a_begin_end_pair_that_drew_nothing_is_still_a_frame() { /// regression in which the key stopped working was invisible. #[test] fn a_superseded_frame_no_longer_satisfies_a_wait() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf '\033[?2026h\033[2J\033[HSTATE-A\033[?2026l'; read a; ", - r"printf '\033[?2026h\033[2J\033[HSTATE-B\033[?2026l'; read b" - ), - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &[ + "--raw", + r"\e[?2026h\e[2J\e[HSTATE-A\e[?2026l", + "--wait", + "--raw", + r"\e[?2026h\e[2J\e[HSTATE-B\e[?2026l", + "--wait", + ], + )?; assert!(t.wait_frame(|s| s.contains("STATE-A"))?.contains("STATE-A")); t.send(Key::Enter)?; @@ -340,13 +346,10 @@ fn a_superseded_frame_no_longer_satisfies_a_wait() -> termlens::Result<()> { #[test] fn one_frame_cannot_satisfy_two_waits() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - r"printf '\033[?2026h\033[2J\033[HONLY-FRAME\033[?2026l'; read guard", - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &["--raw", r"\e[?2026h\e[2J\e[HONLY-FRAME\e[?2026l", "--wait"], + )?; t.wait_frame(|s| s.contains("ONLY-FRAME"))?; let again = t.wait_frame_for(|s| s.contains("ONLY-FRAME"), Duration::from_millis(700)); @@ -364,17 +367,10 @@ fn one_frame_cannot_satisfy_two_waits() -> termlens::Result<()> { /// is *not*: a frame already returned is behind the cursor. #[test] fn a_burst_frame_asked_for_out_of_order_is_gone() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_millis(700)) - .args([ - "-c", - concat!( - r"printf '\033[?2026h\033[HSTEP 1\033[?2026l", - r"\033[?2026h\033[HSTEP 2\033[?2026l", - r"\033[?2026h\033[HSTEP 3\033[?2026l'; read guard" - ), - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(700)), + &["--raw", BURST_OF_THREE, "--wait"], + )?; t.wait_until(|s| s.contains("STEP 3"))?; t.wait_frame(|s| s.contains("STEP 3"))?; @@ -391,15 +387,17 @@ fn a_burst_frame_asked_for_out_of_order_is_gone() -> termlens::Result<()> { /// from the live grid by the time the call returns. #[test] fn the_returned_frame_is_the_matched_instant_not_the_live_screen() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - // One complete frame, then unbracketed output that lands after - // the frame was published. - r"printf '\033[?2026h\033[2J\033[HFRAMED\033[?2026l'; printf '\r\nLIVE'; read guard", - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder().timeout(Duration::from_secs(10)), + // One complete frame, then unbracketed output that lands after + // the frame was published. + &[ + "--raw", + r"\e[?2026h\e[2J\e[HFRAMED\e[?2026l", + "\r\nLIVE", + "--wait", + ], + )?; let frame = t.wait_frame(|s| s.contains("FRAMED"))?; t.wait_until(|s| s.contains("LIVE"))?; @@ -424,15 +422,17 @@ fn the_returned_frame_is_the_matched_instant_not_the_live_screen() -> termlens:: /// hold for `wait_frame`. #[test] fn a_resize_stops_offering_frames_drawn_at_the_old_size() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(80, 24) - .timeout(Duration::from_millis(700)) - .args([ - "-c", - // Paints one frame, then ignores SIGWINCH and never repaints. - r"printf '\033[?2026h\033[2J\033[HBEFORE-RESIZE\033[?2026l'; read guard", - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder() + .size(80, 24) + .timeout(Duration::from_millis(700)), + // Paints one frame, then ignores SIGWINCH and never repaints. + &[ + "--raw", + r"\e[?2026h\e[2J\e[HBEFORE-RESIZE\e[?2026l", + "--wait", + ], + )?; // Deliberately not consumed: this proves the resize moves the cursor, // not that an earlier wait did. @@ -474,19 +474,21 @@ fn the_repaint_answering_a_resize_is_offered_to_wait_frame() -> termlens::Result /// diagnosis. `wait_frame`'s return value is the frame-consistent read. #[test] fn a_snapshot_can_be_mid_frame_for_a_synchronized_application() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(80, 24) - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - // Frame OPEN, one of two rows painted. - r"printf '\033[?2026h\033[2J\033[HROW-ONE'; read a; ", - // Second row, then the frame closes. - r"printf '\033[2;1HROW-TWO\033[?2026l'; read b" - ), - ]) - .spawn("sh")?; + let mut t = emit( + Terminal::builder() + .size(80, 24) + .timeout(Duration::from_secs(10)), + &[ + // Frame OPEN, one of two rows painted. + "--raw", + r"\e[?2026h\e[2J\e[HROW-ONE", + "--wait", + // Second row, then the frame closes. + "--raw", + r"\e[2;1HROW-TWO\e[?2026l", + "--wait", + ], + )?; t.wait_until(|s| s.contains("ROW-ONE"))?; let torn = t.screen(); @@ -514,11 +516,11 @@ fn a_snapshot_can_be_mid_frame_for_a_synchronized_application() -> termlens::Res /// nonsense next to a quiet terminal. #[test] fn a_wait_idle_timeout_names_an_unfinished_frame() { - let mut t = Terminal::builder() - .timeout(Duration::from_millis(600)) - .args(["-c", r"printf '\033[?2026hhalf a frame'; read guard"]) - .spawn("sh") - .unwrap(); + let mut t = emit( + Terminal::builder().timeout(Duration::from_millis(600)), + &["--raw", r"\e[?2026hhalf a frame", "--wait"], + ) + .unwrap(); t.wait_until(|s| s.contains("half a frame")).unwrap(); let err = t.wait_idle(Duration::from_millis(100)).unwrap_err(); @@ -607,19 +609,20 @@ fn the_timing_series_shows_a_deliberately_slow_repaint() -> termlens::Result<()> /// are stamped at the byte that carried them, not when the read landed. #[test] fn a_burst_in_one_read_is_timed_per_frame() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(40, 6) - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf READY; read a; ", - // Three frames in a single write, so they arrive as one read. - r"printf '\033[?2026hone\033[?2026l\033[?2026htwotwo\033[?2026l\033[?2026hthree!\033[?2026l'; ", - r"printf ' DONE'; read b" - ), - ]) - .spawn("/bin/sh")?; + let mut t = emit( + Terminal::builder() + .size(40, 6) + .timeout(Duration::from_secs(10)), + &[ + "READY", + "--wait", + // Three frames in a single write, so they arrive as one read. + "--raw", + r"\e[?2026hone\e[?2026l\e[?2026htwotwo\e[?2026l\e[?2026hthree!\e[?2026l", + " DONE", + "--wait", + ], + )?; t.wait_until(|s| s.contains("READY"))?; assert!(t.frame_timings().is_empty(), "nothing has repainted yet"); diff --git a/crates/termlens/tests/input.rs b/crates/termlens/tests/input.rs index 1554bc9..8d04c14 100644 --- a/crates/termlens/tests/input.rs +++ b/crates/termlens/tests/input.rs @@ -1,5 +1,10 @@ //! Typed input beyond plain keys: mouse (mode-aware), modifier chords, //! bracketed paste, and cursor-key modes. +//! +//! The programs that read what termlens typed off the wire stay on +//! `/bin/sh` on purpose (#249): they put the terminal in raw mode with +//! `stty -icanon -echo` first, and the std-only `emit` fixture cannot set a +//! terminal mode. Everything that only prints goes through the fixture. use std::time::{Duration, Instant}; @@ -332,10 +337,10 @@ fn press_release_tracking_still_gets_no_motion_at_all() -> termlens::Result<()> /// the same standard `click` applies to "no tracking at all". #[test] fn drag_is_refused_when_the_mode_cannot_express_it() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", r"printf '\033[?9h'; printf READY; read guard"]) - .spawn("/bin/sh")?; + let mut t = util::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &["--csi", "?9h", "READY", "--wait"], + )?; t.wait_until(|s| s.contains("READY"))?; let err = t @@ -517,10 +522,10 @@ fn focus_events_are_refused_without_mode_1004() -> termlens::Result<()> { /// failure that could only reach a test by aborting it. #[test] fn typed_input_to_a_departed_child_is_a_typed_error() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", "printf bye"]) - .spawn("/bin/sh")?; + let mut t = util::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &["bye"], + )?; assert!(t.wait_exit()?.success()); let err = t.send(Key::Enter).unwrap_err(); @@ -541,10 +546,10 @@ fn typed_input_to_a_departed_child_is_a_typed_error() -> termlens::Result<()> { /// technically true and never the reason. #[test] fn a_mouse_click_at_a_departed_child_blames_the_child() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", "printf bye"]) - .spawn("/bin/sh")?; + let mut t = util::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &["bye"], + )?; assert!(t.wait_exit()?.success()); for err in [ @@ -570,10 +575,10 @@ fn a_mouse_click_at_a_departed_child_blames_the_child() -> termlens::Result<()> /// other. #[test] fn a_departed_child_is_refused_identically_on_every_platform() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", "printf bye"]) - .spawn("/bin/sh")?; + let mut t = util::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &["bye"], + )?; assert!(t.wait_exit()?.success()); // Not "the write failed" — the terminal is closed, so there is nothing // to write to, and that verdict is reached before any syscall. @@ -590,16 +595,13 @@ fn a_departed_child_is_refused_identically_on_every_platform() -> termlens::Resu /// exactly as they would on a real terminal, and the write succeeded. #[test] fn typed_input_to_a_live_child_that_has_not_read_yet_succeeds() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - "printf READY; sleep 0.4; read line; printf ' got:%s' \"$line\"", - ]) - .spawn("/bin/sh")?; + let mut t = util::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(10)), + &["READY", "--sleep", "400ms", " got:", "--echo-line"], + )?; t.wait_until(|s| s.contains("READY"))?; - // Sent while the child is sleeping, well before its `read`. + // Sent while the child is sleeping, well before its read. t.send_str("pending\n")?; t.wait_until(|s| s.contains("got:pending"))?; assert!(t.wait_exit()?.success()); diff --git a/crates/termlens/tests/observe.rs b/crates/termlens/tests/observe.rs index 382cd93..039e1fc 100644 --- a/crates/termlens/tests/observe.rs +++ b/crates/termlens/tests/observe.rs @@ -7,12 +7,17 @@ use std::time::Duration; use termlens::{Key, Terminal}; -fn sh(script: &str) -> termlens::Result { - Terminal::builder() - .size(40, 6) - .timeout(Duration::from_secs(10)) - .args(["-c", script]) - .spawn("/bin/sh") +mod common; + +/// The `emit` fixture on a 40x6 terminal; steps are documented in +/// `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit( + Terminal::builder() + .size(40, 6) + .timeout(Duration::from_secs(10)), + steps, + ) } /// The amplification assertion: one input must not become N repaints. No @@ -20,14 +25,19 @@ fn sh(script: &str) -> termlens::Result { /// correct content. #[test] fn repaints_count_completed_updates_not_changes() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf READY; read a; ", + let mut t = emit(&[ + "READY", + "--wait", // Three complete repaints, the middle one changing nothing at all. - r"printf '\033[?2026hone\033[?2026l'; ", - r"printf '\033[?2026h\033[?2026l'; ", - r"printf '\033[?2026htwo\033[?2026l'; ", - r"printf ' DONE'; read b" - ))?; + "--raw", + r"\e[?2026hone\e[?2026l", + "--raw", + r"\e[?2026h\e[?2026l", + "--raw", + r"\e[?2026htwo\e[?2026l", + " DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("READY"))?; assert_eq!(t.screen().repaints(), 0, "nothing has repainted yet"); @@ -48,7 +58,7 @@ fn repaints_count_completed_updates_not_changes() -> termlens::Result<()> { /// says zero rather than guessing from its redraws. #[test] fn an_app_without_synchronized_output_reports_no_repaints() -> termlens::Result<()> { - let mut t = sh(r"printf 'drew\n'; printf 'drew again\n'; printf DONE; read g")?; + let mut t = emit(&["drew\n", "drew again\n", "DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; assert_eq!(t.screen().repaints(), 0); t.send(Key::Enter)?; @@ -60,11 +70,18 @@ fn an_app_without_synchronized_output_reports_no_repaints() -> termlens::Result< /// key is refused with a bell" are different behaviours and the same screen. #[test] fn a_bell_is_observable_and_a_title_terminator_is_not_a_bell() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf READY; read a; ", - r"printf '\007'; printf '\033]0;set by osc\007'; printf '\007'; ", - r"printf ' DONE'; read b" - ))?; + let mut t = emit(&[ + "READY", + "--wait", + "--raw", + r"\a", + "--raw", + r"\e]0;set by osc\a", + "--raw", + r"\a", + " DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("READY"))?; let before = t.screen().bells(); assert_eq!(before, 0); @@ -90,7 +107,7 @@ fn a_bell_is_observable_and_a_title_terminator_is_not_a_bell() -> termlens::Resu /// image. #[test] fn graphics_payloads_are_observable_and_absence_is_assertable() -> termlens::Result<()> { - let mut plain = sh(r"printf 'box art: +--+'; printf ' DONE'; read g")?; + let mut plain = emit(&["box art: +--+", " DONE", "--wait"])?; plain.wait_until(|s| s.contains("DONE"))?; assert!( plain.screen().graphics().is_empty(), @@ -99,12 +116,15 @@ fn graphics_payloads_are_observable_and_absence_is_assertable() -> termlens::Res plain.send(Key::Enter)?; assert!(plain.wait_exit()?.success()); - let mut drawing = sh(concat!( - r"printf 'text'; ", - r"printf '\033_Gf=24,s=1,v=1,a=T;QUJDREVG\033\\'; ", - r"printf '\033Pq#0;2;0;0;0#0~~-~~\033\\'; ", - r"printf ' DONE'; read g" - ))?; + let mut drawing = emit(&[ + "text", + "--raw", + r"\e_Gf=24,s=1,v=1,a=T;QUJDREVG\e\\", + "--raw", + r"\ePq#0;2;0;0;0#0~~-~~\e\\", + " DONE", + "--wait", + ])?; drawing.wait_until(|s| s.contains("DONE"))?; let g = drawing.screen().graphics(); assert_eq!(g.kitty(), 1, "one kitty payload"); @@ -128,14 +148,12 @@ fn graphics_payloads_are_observable_and_absence_is_assertable() -> termlens::Res /// answer *and* no diagnosis, alone among the startup probes. #[test] fn a_blocked_kitty_graphics_query_is_named_in_the_timeout() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(40, 4) - .timeout(Duration::from_millis(700)) - .args([ - "-c", - r"stty -icanon -echo; printf '\033_Gi=1,a=q;\033\\'; printf MARK; read g", - ]) - .spawn("/bin/sh")?; + let mut t = common::spawn_emit( + Terminal::builder() + .size(40, 4) + .timeout(Duration::from_millis(700)), + &["--raw", r"\e_Gi=1,a=q;\e\\", "MARK", "--wait"], + )?; let err = t .wait_until(|s| s.contains("NEVER-APPEARS")) .expect_err("must time out"); @@ -153,14 +171,12 @@ fn a_blocked_kitty_graphics_query_is_named_in_the_timeout() -> termlens::Result< /// query diagnosis into an unrelated timeout of an application that draws. #[test] fn a_kitty_transmission_does_not_pollute_the_timeout() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(40, 4) - .timeout(Duration::from_millis(700)) - .args([ - "-c", - r"printf '\033_Gf=24,a=T;QUJD\033\\'; printf MARK; read g", - ]) - .spawn("/bin/sh")?; + let mut t = common::spawn_emit( + Terminal::builder() + .size(40, 4) + .timeout(Duration::from_millis(700)), + &["--raw", r"\e_Gf=24,a=T;QUJD\e\\", "MARK", "--wait"], + )?; let err = t .wait_until(|s| s.contains("NEVER-APPEARS")) .expect_err("must time out"); @@ -180,12 +196,15 @@ fn a_kitty_transmission_does_not_pollute_the_timeout() -> termlens::Result<()> { /// count and left it at zero. #[test] fn a_frame_from_wait_frame_carries_the_repaint_count() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf READY; read a; ", - r"printf '\033[?2026hone\033[?2026l'; ", - r"printf '\033[?2026htwo\033[?2026l'; ", - r"read b" - ))?; + let mut t = emit(&[ + "READY", + "--wait", + "--raw", + r"\e[?2026hone\e[?2026l", + "--raw", + r"\e[?2026htwo\e[?2026l", + "--wait", + ])?; t.wait_until(|s| s.contains("READY"))?; t.send(Key::Enter)?; diff --git a/crates/termlens/tests/process.rs b/crates/termlens/tests/process.rs index 309d84a..057c78c 100644 --- a/crates/termlens/tests/process.rs +++ b/crates/termlens/tests/process.rs @@ -7,16 +7,24 @@ use std::time::{Duration, Instant}; use termlens::Signal; use termlens::{Error, Key, Terminal}; +mod common; + +/// The `emit` fixture; steps are documented in `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit(Terminal::builder().timeout(Duration::from_secs(10)), steps) +} + #[test] fn current_dir_runs_the_child_where_asked() -> termlens::Result<()> { - // Canonicalize: /tmp is a symlink on macOS and `pwd` reports the real + // Canonicalize: /tmp is a symlink on macOS and `--cwd` reports the real // path the kernel put the process in. let dir = std::env::temp_dir().canonicalize()?; - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .current_dir(&dir) - .args(["-c", "pwd; read _"]) - .spawn("sh")?; + let mut t = common::spawn_emit( + Terminal::builder() + .timeout(Duration::from_secs(10)) + .current_dir(&dir), + &["--cwd", "--wait"], + )?; t.wait_until(|s| s.contains(dir.to_str().expect("utf-8 temp dir")))?; t.send(Key::Enter)?; assert!(t.wait_exit()?.success()); @@ -25,12 +33,9 @@ fn current_dir_runs_the_child_where_asked() -> termlens::Result<()> { #[test] fn pid_reports_the_direct_child() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", r#"printf 'pid:%s;' "$$"; read _"#]) - .spawn("sh")?; + let mut t = emit(&["pid:", "--pid", ";", "--wait"])?; let pid = t.pid().expect("unix reports pids"); - // The shell's $$ is the exact process the harness spawned. + // The fixture's own id is the exact process the harness spawned. t.wait_until(|s| s.contains(&format!("pid:{pid};")))?; t.send(Key::Enter)?; assert!(t.wait_exit()?.success()); @@ -40,6 +45,8 @@ fn pid_reports_the_direct_child() -> termlens::Result<()> { #[test] #[cfg(unix)] fn signal_term_exercises_the_graceful_shutdown_path() -> termlens::Result<()> { + // Stays on `/bin/sh`: trapping a signal is what this test is about, and + // the std-only fixture has no way to install a handler. let mut t = Terminal::builder() .timeout(Duration::from_secs(10)) .args([ @@ -60,11 +67,7 @@ fn signal_term_exercises_the_graceful_shutdown_path() -> termlens::Result<()> { #[test] #[cfg(unix)] fn signal_after_reap_is_a_typed_error_not_a_stray_kill() { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", "exit 0"]) - .spawn("sh") - .unwrap(); + let mut t = emit(&["--exit", "0"]).unwrap(); t.wait_exit().unwrap(); let err = t.signal(Signal::Term).unwrap_err(); @@ -79,10 +82,10 @@ fn signal_after_reap_is_a_typed_error_not_a_stray_kill() { fn wait_until_for_overrides_the_default_timeout_upward() -> termlens::Result<()> { // Builder default far below the app's readiness; only the per-call // override can see this through. - let mut t = Terminal::builder() - .timeout(Duration::from_millis(200)) - .args(["-c", "sleep 1; printf late-bloomer; read _"]) - .spawn("sh")?; + let mut t = common::spawn_emit( + Terminal::builder().timeout(Duration::from_millis(200)), + &["--sleep", "1s", "late-bloomer", "--wait"], + )?; t.wait_until_for(|s| s.contains("late-bloomer"), Duration::from_secs(30))?; t.send(Key::Enter)?; assert!(t.wait_exit()?.success()); @@ -91,11 +94,11 @@ fn wait_until_for_overrides_the_default_timeout_upward() -> termlens::Result<()> #[test] fn wait_until_for_overrides_the_default_timeout_downward() { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(30)) - .args(["-c", "read _"]) - .spawn("sh") - .unwrap(); + let mut t = common::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(30)), + &["--wait"], + ) + .unwrap(); let start = Instant::now(); let err = t .wait_until_for(|s| s.contains("never shown"), Duration::from_millis(100)) @@ -118,10 +121,7 @@ fn wait_until_for_overrides_the_default_timeout_downward() { #[test] #[cfg(unix)] fn a_signalled_child_reports_no_exit_code() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", "printf READY; read guard"]) - .spawn("/bin/sh")?; + let mut t = emit(&["READY", "--wait"])?; t.wait_until(|s| s.contains("READY"))?; t.signal(termlens::Signal::Term)?; @@ -148,10 +148,7 @@ fn a_signalled_child_reports_no_exit_code() -> termlens::Result<()> { /// wrapped in `Some`. #[test] fn a_normally_exited_child_still_reports_its_code() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", "exit 7"]) - .spawn("/bin/sh")?; + let mut t = emit(&["--exit", "7"])?; let status = t.wait_exit()?; assert_eq!(status.code(), Some(7), "status: {status}"); assert_eq!(status.signal(), None); diff --git a/crates/termlens/tests/queries.rs b/crates/termlens/tests/queries.rs index dbce498..17a8ae0 100644 --- a/crates/termlens/tests/queries.rs +++ b/crates/termlens/tests/queries.rs @@ -4,11 +4,20 @@ //! Each shell script here genuinely BLOCKS on the terminal's reply //! (`head -c N` reads exactly the reply bytes), then prints a marker the //! test waits for — the marker appearing proves the app was unblocked. +//! +//! Those stay on `/bin/sh` on purpose (#249): reading a reply byte for byte +//! needs the terminal in raw mode (`stty -icanon -echo`), which the +//! std-only `emit` fixture cannot set. The programs that only *ask* and +//! then block on a line — the ones whose point is that no answer comes — +//! go through the fixture: `--wait` blocks on a line exactly as `head` did +//! in canonical mode. use std::time::Duration; use termlens::{Error, Key, Terminal}; +mod common; + fn sh(script: &str) -> termlens::Result { Terminal::builder() .timeout(Duration::from_secs(10)) @@ -16,6 +25,12 @@ fn sh(script: &str) -> termlens::Result { .spawn("/bin/sh") } +/// The `emit` fixture with the timeout the test names; steps are documented +/// in `fixtures/emit/src/main.rs`. +fn emit(timeout: Duration, steps: &[&str]) -> termlens::Result { + common::spawn_emit(Terminal::builder().timeout(timeout), steps) +} + #[test] fn cursor_position_reports_the_position_at_the_query() -> termlens::Result<()> { // After printing "abc" the cursor sits at row 1, col 4 (1-based on the @@ -107,11 +122,11 @@ fn text_area_size_reports_the_real_grid() -> termlens::Result<()> { fn unanswerable_queries_turn_timeouts_into_diagnoses() { // CSI 14 t (pixel size) is recognized as a question termlens cannot // answer; the app blocks, and the timeout error names the query. - 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 mut t = emit( + Duration::from_millis(500), + &["--csi", "14t", "--wait", "never"], + ) + .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}"); @@ -121,12 +136,13 @@ fn unanswerable_queries_turn_timeouts_into_diagnoses() { #[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 mut t = common::spawn_emit( + Terminal::builder() + .timeout(Duration::from_millis(500)) + .answer_queries(false), + &["--csi", "6n", "--wait", "never"], + ) + .unwrap(); let err = t.wait_until(|s| s.contains("never")).unwrap_err(); assert!(matches!(err, Error::Timeout { .. })); let msg = err.to_string(); @@ -139,20 +155,17 @@ fn the_responder_can_be_disabled_and_says_what_went_unanswered() { /// not blame it. #[test] fn a_query_the_app_moved_past_is_context_not_a_cause() { - let mut t = Terminal::builder() - .timeout(Duration::from_millis(400)) + let mut t = emit( + Duration::from_millis(400), // Probes kitty (deliberately unanswered), does NOT block on a // reply, prints, then sits in a normal read. The pause forces the // output into a *later read* than the probe — output batched into // the same write is deliberately not treated as progress, since // the emulator stops at the query byte and consumes the rest of // that same chunk regardless of what the application is doing. - .args([ - "-c", - r"printf '\033[?u'; sleep 0.2; printf 'ready\n'; read guard", - ]) - .spawn("/bin/sh") - .unwrap(); + &["--csi", "?u", "--sleep", "200ms", "ready\n", "--wait"], + ) + .unwrap(); t.wait_until(|s| s.contains("ready")).unwrap(); let err = t.wait_until(|s| s.contains("never-appears")).unwrap_err(); @@ -174,14 +187,11 @@ fn a_query_the_app_moved_past_is_context_not_a_cause() { /// Every unanswered query is named, not just the most recent one. #[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 mut t = emit( + Duration::from_millis(400), + &["--raw", r"\e[?u\e[14t", "--wait", "never"], + ) + .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}"); @@ -194,11 +204,11 @@ fn all_unanswered_queries_are_named() { /// then blames the app for not emitting frames. #[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 mut t = emit( + Duration::from_millis(400), + &["--csi", "14t", "--wait", "never"], + ) + .unwrap(); let err = t.wait_frame(|s| s.contains("never")).unwrap_err(); let msg = err.to_string(); assert!( @@ -257,23 +267,15 @@ fn mode_reports_are_truthful() -> termlens::Result<()> { /// timeout instead of hanging silently. #[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;?", - ), + for (label, query, shape) in [ + ("DECRQSS", r"\eP$qm\e\\", "^[P$qm"), + ("OSC 4", r"\e]4;1;?\a", "^[]4;1;?"), ] { - let mut t = Terminal::builder() - .timeout(Duration::from_millis(400)) - .args(["-c", script]) - .spawn("/bin/sh") - .unwrap(); + let mut t = emit( + Duration::from_millis(400), + &["--raw", query, "--wait", "never"], + ) + .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}"); @@ -437,14 +439,12 @@ fn decrqm_answers_for_focus_reporting() -> termlens::Result<()> { #[test] fn cell_size_answers_the_pixel_reports_and_the_ioctl() -> termlens::Result<()> { // Unset: no reply, and the query is named in the next timeout. - 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 mut mute = common::spawn_emit( + Terminal::builder() + .size(80, 6) + .timeout(Duration::from_millis(700)), + &["--csi", "16t", "MARK", "--wait"], + )?; let err = mute .wait_until(|s| s.contains("NEVER")) .expect_err("must time out"); diff --git a/crates/termlens/tests/scrollback.rs b/crates/termlens/tests/scrollback.rs index 8125e73..e847921 100644 --- a/crates/termlens/tests/scrollback.rs +++ b/crates/termlens/tests/scrollback.rs @@ -6,21 +6,19 @@ use std::time::Duration; use termlens::{Error, Key, Terminal}; -/// `sh` printing `count` numbered lines on a `rows`-row screen, then -/// parking so the terminal stays alive. +mod common; + +/// The `emit` fixture printing `count` numbered lines on a `rows`-row +/// screen, then parking so the terminal stays alive. fn numbered(rows: u16, count: usize, scrollback: usize) -> termlens::Result { - Terminal::builder() - .size(40, rows) - .scrollback(scrollback) - .timeout(Duration::from_secs(10)) - .args([ - "-c", - &format!( - "i=1; while [ $i -le {count} ]; do printf 'line-%d\\n' $i; \ - i=$((i+1)); done; printf 'READY'; read guard" - ), - ]) - .spawn("/bin/sh") + let lines: String = (1..=count).map(|i| format!("line-{i}\n")).collect(); + common::spawn_emit( + Terminal::builder() + .size(40, rows) + .scrollback(scrollback) + .timeout(Duration::from_secs(10)), + &[&lines, "READY", "--wait"], + ) } #[test] @@ -118,15 +116,13 @@ fn retention_can_be_switched_off() -> termlens::Result<()> { /// usable in a wait predicate. #[test] fn history_is_observable_from_a_predicate() -> termlens::Result<()> { - let mut t = Terminal::builder() - .size(40, 3) - .scrollback(100) - .timeout(Duration::from_secs(10)) - .args([ - "-c", - r"printf 'committed-block\n'; printf 'a\nb\nc\nd\n'; read guard", - ]) - .spawn("/bin/sh")?; + let mut t = common::spawn_emit( + Terminal::builder() + .size(40, 3) + .scrollback(100) + .timeout(Duration::from_secs(10)), + &["committed-block\n", "a\nb\nc\nd\n", "--wait"], + )?; // The block is asserted on *after* it has left the screen, from inside // the wait itself. diff --git a/crates/termlens/tests/stable.rs b/crates/termlens/tests/stable.rs index 4f185f7..e519dcd 100644 --- a/crates/termlens/tests/stable.rs +++ b/crates/termlens/tests/stable.rs @@ -5,13 +5,18 @@ use std::time::{Duration, Instant}; use termlens::{Error, Key, Terminal}; -fn sh(script: &str) -> termlens::Result { - Terminal::builder() - .size(40, 6) - .env_clear() - .timeout(Duration::from_secs(10)) - .args(["-c", script]) - .spawn("/bin/sh") +mod common; + +/// The `emit` fixture on a 40x6 terminal; steps are documented in +/// `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit( + Terminal::builder() + .size(40, 6) + .env_clear() + .timeout(Duration::from_secs(10)), + steps, + ) } /// Two paints two seconds apart: the settle ends between them, so the @@ -19,7 +24,7 @@ fn sh(script: &str) -> termlens::Result { /// held still, not a later `screen()`. #[test] fn snapshot_after_returns_the_screen_once_it_holds_still() -> termlens::Result<()> { - let mut t = sh("printf first; sleep 2; printf ' second'; read _")?; + let mut t = emit(&["first", "--sleep", "2s", " second", "--wait"])?; let screen = t.snapshot_after(|s| s.contains("first"))?; assert_eq!(screen.row_text(0).trim_end(), "first", "{screen}"); t.wait_until(|s| s.contains("second"))?; @@ -32,7 +37,7 @@ fn snapshot_after_returns_the_screen_once_it_holds_still() -> termlens::Result<( /// snapshot was never taken of a screen that never showed the thing. #[test] fn snapshot_after_fails_on_the_predicate_before_it_settles() -> termlens::Result<()> { - let mut t = sh("printf other; read _")?; + let mut t = emit(&["other", "--wait"])?; let err = t .snapshot_after_for(|s| s.contains("never"), Duration::from_millis(300)) .unwrap_err(); @@ -52,15 +57,16 @@ fn snapshot_after_fails_on_the_predicate_before_it_settles() -> termlens::Result /// silence through them; `wait_stable` settles, and the screen it hands /// back carries the bell count as of the newest byte, not as of the paint. /// -/// The bell loop is a shell builtin with no `sleep`: a process spawned per -/// iteration stalls for hundreds of milliseconds on a loaded machine, and -/// a stall is real silence — the stress workflow found exactly that. The +/// The bell loop is one write per iteration and nothing else — no `sleep`, +/// and no process spawned per iteration, which used to stall for hundreds +/// of milliseconds on a loaded machine, and a stall is real silence; the +/// stress workflow found exactly that against a shell loop. The /// `wait_idle` expectation is made load-proof the same way the deadline /// tests are: two seconds of silence cannot be observed inside a 400ms /// deadline unless the stream had already been silent for most of it. #[test] fn wait_stable_ignores_output_that_changes_no_cell() -> termlens::Result<()> { - let mut t = sh(r"printf noisy; while :; do printf '\a'; done")?; + let mut t = emit(&["noisy", "--loop", "--raw", r"\a"])?; t.wait_until(|s| s.contains("noisy") && s.bells() > 0)?; let idle = t.wait_idle_for(Duration::from_secs(2), Duration::from_millis(400)); @@ -87,7 +93,7 @@ fn wait_stable_ignores_output_that_changes_no_cell() -> termlens::Result<()> { /// A picture that keeps changing never settles, and the timeout says what /// was waited for, with the deadline that applied. /// -/// `seq` scrolls a new number onto the screen as fast as the PTY takes +/// `--seq` scrolls a new number onto the screen as fast as the PTY takes /// them — one process, no per-iteration spawn to stall under load — and /// the stillness asked for (2s) is far longer than the deadline (400ms), /// so the wait can only succeed if the child had already been stalled for @@ -98,7 +104,7 @@ fn wait_stable_ignores_output_that_changes_no_cell() -> termlens::Result<()> { /// `wait_stable` was right to say so. #[test] fn wait_stable_times_out_while_the_picture_keeps_changing() -> termlens::Result<()> { - let mut t = sh("seq 1 100000000")?; + let mut t = emit(&["--seq", "100000000"])?; // Any digit: which numbers are on screen when a snapshot lands is // whatever the flood happens to show, so no particular one is waited for. t.wait_until(|s| s.text().chars().any(|c| c.is_ascii_digit()))?; @@ -128,7 +134,14 @@ fn wait_stable_times_out_while_the_picture_keeps_changing() -> termlens::Result< /// settle at once. #[test] fn wait_stable_does_not_settle_inside_an_open_synchronized_update() -> termlens::Result<()> { - let mut t = sh(r"printf '\033[?2026hhalf'; read _; printf '\033[?2026l'; read _")?; + let mut t = emit(&[ + "--raw", + r"\e[?2026hhalf", + "--wait", + "--csi", + "?2026l", + "--wait", + ])?; t.wait_until(|s| s.contains("half"))?; let err = t .wait_stable_for(Duration::from_millis(50), Duration::from_millis(400)) @@ -154,7 +167,7 @@ fn wait_stable_does_not_settle_inside_an_open_synchronized_update() -> termlens: /// exited child's final screen is still by definition. #[test] fn a_screen_that_already_holds_still_settles_at_once() -> termlens::Result<()> { - let mut t = sh("printf settled; read _")?; + let mut t = emit(&["settled", "--wait"])?; t.wait_until(|s| s.contains("settled"))?; std::thread::sleep(Duration::from_millis(1200)); let start = Instant::now(); diff --git a/crates/termlens/tests/state.rs b/crates/termlens/tests/state.rs index 8f29c24..e33833b 100644 --- a/crates/termlens/tests/state.rs +++ b/crates/termlens/tests/state.rs @@ -6,26 +6,31 @@ use std::time::Duration; use termlens::{CursorShape, Key, MouseMode, MouseModes, Screen, Terminal}; -/// One script walks the whole state surface: set everything, assert, then +mod common; + +/// The `emit` fixture; steps are documented in `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit(Terminal::builder().timeout(Duration::from_secs(10)), steps) +} + +/// One program walks the whole state surface: set everything, assert, then /// unwind everything and assert the way back. #[test] fn screen_reports_title_alternate_screen_and_input_modes() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf '\033]0;termlens state\007'; ", - r"printf '\033[?1049h\033[?2004h\033[?1h\033[?1002h'; ", - r"printf 'modes: on'; ", - r"read _; ", - r"printf '\033]2;phase two\033\\'; ", - r"printf '\033[?1002l\033[?1l\033[?2004l\033[?1049l'; ", - r"printf 'modes: off'; ", - r"read _", - ), - ]) - .spawn("sh")?; + let mut t = emit(&[ + "--raw", + r"\e]0;termlens state\x07", + "--raw", + r"\e[?1049h\e[?2004h\e[?1h\e[?1002h", + "modes: on", + "--wait", + "--raw", + r"\e]2;phase two\e\\", + "--raw", + r"\e[?1002l\e[?1l\e[?2004l\e[?1049l", + "modes: off", + "--wait", + ])?; // State assertions are ordinary predicates — waitable like any text. t.wait_until(|s| { @@ -55,23 +60,16 @@ fn screen_reports_title_alternate_screen_and_input_modes() -> termlens::Result<( /// The tracking mode the app enabled is reported by name, not collapsed. /// `DECSCUSR` leaves the grid identical, so without an accessor a screen /// where the application asked for a bar and one where it never asked are -/// the same `Screen`. The script walks all three states an editor moves +/// the same `Screen`. The program walks all three states an editor moves /// through: never asked, switched, switched back. #[test] fn screen_reports_the_cursor_shape_the_application_asked_for() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf 'ready'; read _; ", - // DECSCUSR 5: a blinking bar, the insert-mode cursor. - r"printf '\033[5 q'; printf ' insert'; read _; ", - // DECSCUSR 2: a steady block, the way back. - r"printf '\033[2 q'; printf ' normal'; read _", - ), - ]) - .spawn("sh")?; + let mut t = emit(&[ + "ready", "--wait", // DECSCUSR 5: a blinking bar, the insert-mode cursor. + "--csi", "5 q", " insert", "--wait", + // DECSCUSR 2: a steady block, the way back. + "--csi", "2 q", " normal", "--wait", + ])?; // Never asked. Distinct from a block, which is what most terminals // happen to draw by default — the point is that the program did not say. @@ -114,20 +112,19 @@ fn screen_reports_the_cursor_shape_the_application_asked_for() -> termlens::Resu /// emitted nothing. #[test] fn an_osc8_hyperlink_is_observable_and_a_missing_one_is_not() -> termlens::Result<()> { - fn run(script: &str) -> termlens::Result { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", script]) - .spawn("sh")?; + fn run(steps: &[&str]) -> termlens::Result { + let mut t = emit(steps)?; t.wait_until(|s| s.contains("see docs here"))?; let screen = t.screen(); assert!(t.wait_exit()?.success()); Ok(screen) } - let linked = - run(r"printf 'see \033]8;;https://example.invalid/a\033\\docs\033]8;;\033\\ here\n'")?; - let plain = run(r"printf 'see docs here\n'")?; + let linked = run(&[ + "--raw", + r"see \e]8;;https://example.invalid/a\e\\docs\e]8;;\e\\ here\n", + ])?; + let plain = run(&["see docs here\n"])?; // The grids agree exactly — this was never a rendering bug, and the URL // must not leak into the cells. @@ -160,18 +157,16 @@ fn an_osc8_hyperlink_is_observable_and_a_missing_one_is_not() -> termlens::Resul /// asserted in a comment. #[test] fn a_snapshot_keeps_its_own_view_of_the_links() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - // Open a span and leave it open across the pause. - r"printf '\033]8;;http://a/\033\\LABEL one\n'; read _; ", - // Close it, then open a second one. - r"printf '\033]8;;\033\\\033]8;;http://b/\033\\X two\n'; read _", - ), - ]) - .spawn("sh")?; + let mut t = emit(&[ + // Open a span and leave it open across the pause. + "--raw", + r"\e]8;;http://a/\e\\LABEL one\n", + "--wait", + // Close it, then open a second one. + "--raw", + r"\e]8;;\e\\\e]8;;http://b/\e\\X two\n", + "--wait", + ])?; t.wait_until(|s| s.contains("one"))?; let early = t.screen(); @@ -212,18 +207,16 @@ fn a_snapshot_keeps_its_own_view_of_the_links() -> termlens::Result<()> { /// a wrong replay — and the alternate screen is left alone, as specified. #[test] fn a_soft_reset_returns_the_modes_a_screen_can_observe() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf '\033[?1049h\033[?1h\033[?2004h\033[?1000h\033[?1006h\033[?1004h\033[?25l\033[5 q'; ", - r"printf 'set'; read _; ", - r"printf '\033[!p'; ", - r"printf ' reset'; read _", - ), - ]) - .spawn("sh")?; + let mut t = emit(&[ + "--raw", + r"\e[?1049h\e[?1h\e[?2004h\e[?1000h\e[?1006h\e[?1004h\e[?25l\e[5 q", + "set", + "--wait", + "--csi", + "!p", + " reset", + "--wait", + ])?; t.wait_until(|s| { s.contains("set") @@ -258,17 +251,17 @@ fn a_soft_reset_returns_the_modes_a_screen_can_observe() -> termlens::Result<()> #[test] fn mouse_mode_reports_the_exact_tracking_mode() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf '\033[?9h9\n'; read _; ", - r"printf '\033[?9l\033[?1000h1000\n'; read _; ", - r"printf '\033[?1000l\033[?1003h1003\n'; read _", - ), - ]) - .spawn("sh")?; + let mut t = emit(&[ + "--raw", + r"\e[?9h9\n", + "--wait", + "--raw", + r"\e[?9l\e[?1000h1000\n", + "--wait", + "--raw", + r"\e[?1000l\e[?1003h1003\n", + "--wait", + ])?; t.wait_until(|s| s.contains("9") && s.mouse_mode() == MouseMode::Press)?; t.send(Key::Enter)?; @@ -288,17 +281,20 @@ fn mouse_mode_reports_the_exact_tracking_mode() -> termlens::Result<()> { /// input path keeps the collapsed value; the set is reported beside it. #[test] fn mouse_modes_reports_the_set_the_application_asked_for() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(10)) - .args([ - "-c", - concat!( - r"printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'; printf 'all three\n'; read _; ", - r"printf '\033[?1003l'; printf 'minus 1003\n'; read _; ", - r"printf '\033[?1002l\033[?1000l'; printf 'none\n'; read _", - ), - ]) - .spawn("sh")?; + let mut t = emit(&[ + "--raw", + r"\e[?1000h\e[?1002h\e[?1003h\e[?1006h", + "all three\n", + "--wait", + "--csi", + "?1003l", + "minus 1003\n", + "--wait", + "--raw", + r"\e[?1002l\e[?1000l", + "none\n", + "--wait", + ])?; let set = |modes: &[MouseMode]| -> Vec { modes.to_vec() }; t.wait_until(|s| { @@ -349,16 +345,15 @@ fn a_clipboard_write_is_observable_with_its_payload() -> termlens::Result<()> { // The taskboard case from the coverage study: `y` copies the selected // title and paints a toast. The toast proves the code path ran; the // payload is the behaviour under test. - let mut t = Terminal::builder() - .timeout(Duration::from_secs(5)) - .args([ - "-c", - concat!( - r"printf '\033]52;c;V2lyZSB1cCB0aGUgUFRZIHJlYWRlcg==\007'; ", - r"printf 'copied to clipboard'; read guard" - ), - ]) - .spawn("/bin/sh")?; + let mut t = common::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(5)), + &[ + "--raw", + r"\e]52;c;V2lyZSB1cCB0aGUgUFRZIHJlYWRlcg==\x07", + "copied to clipboard", + "--wait", + ], + )?; // Assertable in a predicate, because it is snapshot state. t.wait_until(|s| { @@ -383,13 +378,10 @@ fn a_clipboard_write_is_observable_with_its_payload() -> termlens::Result<()> { #[test] fn an_unreadable_clipboard_payload_is_reported_as_such() -> termlens::Result<()> { - let mut t = Terminal::builder() - .timeout(Duration::from_secs(5)) - .args([ - "-c", - r"printf '\033]52;p;not~valid~base64\007'; printf 'done'; read guard", - ]) - .spawn("/bin/sh")?; + let mut t = common::spawn_emit( + Terminal::builder().timeout(Duration::from_secs(5)), + &["--raw", r"\e]52;p;not~valid~base64\x07", "done", "--wait"], + )?; t.wait_until(|s| s.contains("done"))?; let s = t.screen(); diff --git a/crates/termlens/tests/styles.rs b/crates/termlens/tests/styles.rs index 8cb9474..64282b0 100644 --- a/crates/termlens/tests/styles.rs +++ b/crates/termlens/tests/styles.rs @@ -4,20 +4,20 @@ use std::time::Duration; use termlens::{Key, Terminal}; -fn sh(script: &str) -> termlens::Result { - Terminal::builder() - .timeout(Duration::from_secs(10)) - .args(["-c", script]) - .spawn("/bin/sh") +mod common; + +/// The `emit` fixture; steps are documented in `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit(Terminal::builder().timeout(Duration::from_secs(10)), steps) } -/// Print a two-item list with `reverse` on the item given by $1. -fn list_with_highlight(row: u16) -> String { - let (one, two) = match row { - 0 => (r"\033[7mitem one\033[0m", "item two"), - _ => ("item one", r"\033[7mitem two\033[0m"), +/// A two-item list with `reverse` on the item given by `row`. +fn list_with_highlight(row: u16) -> [&'static str; 3] { + let list = match row { + 0 => r"\e[7mitem one\e[0m\nitem two\n", + _ => r"item one\n\e[7mitem two\e[0m\n", }; - format!(r"printf '{one}\n{two}\n'; read guard") + ["--raw", list, "--wait"] } #[test] @@ -31,13 +31,13 @@ fn moving_a_highlight_changes_the_styled_rendering_only() -> termlens::Result<() // 17 of 100, with byte-identical grids and only the cursor differing. let settled = |s: &termlens::Screen| s.contains("item two") && s.cursor() == (2, 0, true); - let mut first = sh(&list_with_highlight(0))?; + let mut first = emit(&list_with_highlight(0))?; first.wait_until(settled)?; let a = first.screen(); first.send(Key::Enter)?; first.wait_exit()?; - let mut second = sh(&list_with_highlight(1))?; + let mut second = emit(&list_with_highlight(1))?; second.wait_until(settled)?; let b = second.screen(); second.send(Key::Enter)?; @@ -56,10 +56,13 @@ fn moving_a_highlight_changes_the_styled_rendering_only() -> termlens::Result<() #[test] fn styled_screen_snapshot() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033[1;31mERROR\033[0m plain \033[4;34munderlined\033[0m\n'; ", - r"printf 'second row \033[7mselected\033[0m\n'; read guard" - ))?; + let mut t = emit(&[ + "--raw", + r"\e[1;31mERROR\e[0m plain \e[4;34munderlined\e[0m\n", + "--raw", + r"second row \e[7mselected\e[0m\n", + "--wait", + ])?; // Same trailing-newline race as above, and a snapshot embeds the cursor: // caught by the stress gate at iteration 46 of 100 as `cursor: 1,19` // against the recorded `cursor: 2,0`. @@ -79,7 +82,7 @@ fn styled_screen_snapshot() -> termlens::Result<()> { fn a_highlight_over_a_wide_character_is_one_span() -> termlens::Result<()> { // A wide character's continuation column used to snapshot unstyled, so // a bar over CJK or emoji rendered as two spans with a hole (#218). - let mut t = sh(r"printf '\033[48;2;30;30;46mab汉cd\033[0m'; read guard")?; + let mut t = emit(&["--raw", r"\e[48;2;30;30;46mab汉cd\e[0m", "--wait"])?; t.wait_until(|s| s.contains("cd"))?; let s = t.screen(); let bar = termlens::Color::Rgb(30, 30, 46); @@ -104,13 +107,13 @@ fn a_masked_field_is_distinguishable_from_clear_text() -> termlens::Result<()> { // differing at all — passing for the wrong reason. let settled = |s: &termlens::Screen| s.contains("pw: hunter2|") && s.cursor() == (0, 12, true); - let mut masked = sh(r"printf 'pw: \033[8mhunter2\033[28m|'; read guard")?; + let mut masked = emit(&["--raw", r"pw: \e[8mhunter2\e[28m|", "--wait"])?; masked.wait_until(settled)?; let a = masked.screen(); masked.send(Key::Enter)?; masked.wait_exit()?; - let mut clear = sh(r"printf 'pw: hunter2|'; read guard")?; + let mut clear = emit(&["pw: hunter2|", "--wait"])?; clear.wait_until(settled)?; let b = clear.screen(); clear.send(Key::Enter)?; @@ -142,10 +145,11 @@ fn a_masked_field_is_distinguishable_from_clear_text() -> termlens::Result<()> { #[test] fn strikethrough_and_blink_appear_in_the_styled_rendering() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf 'done \033[9mship it\033[29m ", - r"\033[5;31moverdue\033[0m plain'; read guard" - ))?; + let mut t = emit(&[ + "--raw", + r"done \e[9mship it\e[29m \e[5;31moverdue\e[0m plain", + "--wait", + ])?; t.wait_until(|s| s.contains("plain"))?; let s = t.screen(); let styled = s.with_styles().to_string(); @@ -169,10 +173,11 @@ fn strikethrough_and_blink_appear_in_the_styled_rendering() -> termlens::Result< #[test] fn colon_form_rgb_colours_match_semicolon_form() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033[38;2;10;20;30mA\033[0m\033[38:2::10:20:30mB\033[0m'; ", - "read guard" - ))?; + let mut t = emit(&[ + "--raw", + r"\e[38;2;10;20;30mA\e[0m\e[38:2::10:20:30mB\e[0m", + "--wait", + ])?; t.wait_until(|s| s.contains("AB"))?; let s = t.screen(); @@ -194,10 +199,7 @@ fn colon_form_rgb_colours_match_semicolon_form() -> termlens::Result<()> { #[test] fn dim_and_italic_appear_without_shadow_collisions() -> termlens::Result<()> { - let mut t = sh(concat!( - r"printf '\033[2mfaint\033[0m ", - r"\033[3mslanted\033[0m\n'; read guard" - ))?; + let mut t = emit(&["--raw", r"\e[2mfaint\e[0m \e[3mslanted\e[0m\n", "--wait"])?; t.wait_until(|s| s.contains("slanted") && s.cursor() == (1, 0, true))?; let s = t.screen(); diff --git a/crates/termlens/tests/tabs.rs b/crates/termlens/tests/tabs.rs index bb3ecfe..0c6a047 100644 --- a/crates/termlens/tests/tabs.rs +++ b/crates/termlens/tests/tabs.rs @@ -10,12 +10,17 @@ use std::time::Duration; use termlens::{Key, Terminal}; -fn sh(script: &str) -> termlens::Result { - Terminal::builder() - .size(24, 4) - .timeout(Duration::from_secs(10)) - .args(["-c", script]) - .spawn("/bin/sh") +mod common; + +/// The `emit` fixture on a 24-column terminal, the width the issue's +/// reproductions use. Steps are documented in `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit( + Terminal::builder() + .size(24, 4) + .timeout(Duration::from_secs(10)), + steps, + ) } /// Where a needle sits, which is the only thing any of these assert. @@ -32,7 +37,9 @@ fn col_of(screen: &termlens::Screen, needle: &str) -> Option { /// have disagreed even with `CHT` working. #[test] fn a_tab_lands_on_a_stop_set_by_hts() -> termlens::Result<()> { - let mut t = sh(r"printf '\033[4G\033H\033[1Ga\011b'; printf ' DONE'; read _")?; + let mut t = emit(&[ + "--csi", "4G", "--esc", "H", "--csi", "1G", "a\tb", " DONE", "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "a"), Some(0), "{s}"); @@ -46,7 +53,9 @@ fn a_tab_lands_on_a_stop_set_by_hts() -> termlens::Result<()> { /// rather than replacing them. #[test] fn the_default_stops_survive_a_custom_one() -> termlens::Result<()> { - let mut t = sh(r"printf '\033[4G\033H\033[1Ga\011b\011c'; printf ' DONE'; read _")?; + let mut t = emit(&[ + "--csi", "4G", "--esc", "H", "--csi", "1G", "a\tb\tc", " DONE", "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "b"), Some(3), "{s}"); @@ -62,7 +71,9 @@ fn the_default_stops_survive_a_custom_one() -> termlens::Result<()> { fn tbc_clears_the_stop_under_the_cursor() -> termlens::Result<()> { // Standing on the default stop at column 9 (one-based), clear it: the // tab from column 1 runs past it to the next, at column 17. - let mut t = sh(r"printf '\033[9G\033[g\033[1Ga\011b'; printf ' DONE'; read _")?; + let mut t = emit(&[ + "--csi", "9G", "--csi", "g", "--csi", "1G", "a\tb", " DONE", "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "b"), Some(16), "{s}"); @@ -79,7 +90,7 @@ fn csi_3_g_clears_every_stop() -> termlens::Result<()> { // `DONE` goes on the next row on purpose: the last column is where the // tabs end up, so anything printed after them on row 0 would overwrite // the very cell under test. - let mut t = sh(r"printf '\033[3ga\011\011b\r\nDONE'; read _")?; + let mut t = emit(&["--csi", "3g", "a\t\tb\r\nDONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "a"), Some(0), "{s}"); @@ -96,7 +107,7 @@ fn csi_3_g_clears_every_stop() -> termlens::Result<()> { /// `CHT` moves forward by whole stops, with a count. #[test] fn cht_moves_forward_by_whole_stops() -> termlens::Result<()> { - let mut t = sh(r"printf '\033[2Ia'; printf ' DONE'; read _")?; + let mut t = emit(&["--csi", "2I", "a", " DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "a"), Some(16), "two stops forward:\n{s}"); @@ -110,7 +121,7 @@ fn cht_moves_forward_by_whole_stops() -> termlens::Result<()> { #[test] fn cbt_moves_back_by_whole_stops() -> termlens::Result<()> { // From a stop, back-tab reaches the one before it. - let mut t = sh(r"printf '\011\011\033[1Zy'; printf ' DONE'; read _")?; + let mut t = emit(&["\t\t", "--csi", "1Z", "y", " DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "y"), Some(8), "{s}"); @@ -129,7 +140,7 @@ fn cbt_moves_back_by_whole_stops() -> termlens::Result<()> { /// would do without the `X` in the way — the case above. #[test] fn a_back_tab_returns_to_the_stop_the_cursor_is_just_past() -> termlens::Result<()> { - let mut t = sh(r"printf '\011\011X\033[1Zy'; printf ' DONE'; read _")?; + let mut t = emit(&["\t\tX", "--csi", "1Z", "y", " DONE", "--wait"])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "y"), Some(16), "{s}"); @@ -142,7 +153,9 @@ fn a_back_tab_returns_to_the_stop_the_cursor_is_just_past() -> termlens::Result< /// `RIS` puts the terminal back to power-on, and the stops with it. #[test] fn a_hard_reset_restores_the_default_stops() -> termlens::Result<()> { - let mut t = sh(r"printf '\033[3g\033[4G\033H\033c\011x'; printf ' DONE'; read _")?; + let mut t = emit(&[ + "--csi", "3g", "--csi", "4G", "--esc", "H", "--esc", "c", "\tx", " DONE", "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "x"), Some(8), "every eighth column again:\n{s}"); @@ -155,7 +168,10 @@ fn a_hard_reset_restores_the_default_stops() -> termlens::Result<()> { /// startup and teardown, which leaves the screen alone. #[test] fn a_soft_reset_restores_the_default_stops() -> termlens::Result<()> { - let mut t = sh(r"printf '\033[3g\033[4G\033H\033[!p\033[1G\011x'; printf ' DONE'; read _")?; + let mut t = emit(&[ + "--csi", "3g", "--csi", "4G", "--esc", "H", "--csi", "!p", "--csi", "1G", "\tx", " DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(col_of(&s, "x"), Some(8), "every eighth column again:\n{s}"); @@ -174,23 +190,33 @@ fn a_soft_reset_restores_the_default_stops() -> termlens::Result<()> { #[test] fn a_resize_extends_the_stops_and_keeps_the_ones_it_had() -> termlens::Result<()> { // The stop at column 4 is set before the resize; `READY` parks the - // child so the widen lands between the two halves of the script. + // child so the widen lands between the two halves. // - // One `read` and no trailing wait: a resize raises `SIGWINCH` in the - // child, which can cut a pending `read` short, so a script that paused + // One `--wait` and no trailing one: a resize raises `SIGWINCH` in the + // child, which can cut a pending read short, so a program that paused // twice would be racing the signal for which pause our one keypress // lands in. With a single pause the second half is printed after the // widen either way. // // Which *row* it is printed on is the other side of that same race, so - // it is deliberately not asserted: when the `read` consumes our Enter - // the terminal echoes the newline and the second half starts a row - // lower than when `SIGWINCH` has already ended it. The column is what - // the resize rule is about, and it is the same either way. - let mut t = sh(concat!( - r"printf '\033[4G\033H\033[1GREADY\r\n'; read _; ", - r"printf '\011a\033[25G\011b\r\nDONE'" - ))?; + // it is deliberately not asserted: when the wait consumes our Enter the + // terminal echoes the newline and the second half starts a row lower + // than when `SIGWINCH` has already ended it. The column is what the + // resize rule is about, and it is the same either way. + let mut t = emit(&[ + "--csi", + "4G", + "--esc", + "H", + "--csi", + "1G", + "READY\r\n", + "--wait", + "\ta", + "--csi", + "25G", + "\tb\r\nDONE", + ])?; t.wait_until(|s| s.contains("READY"))?; t.resize(40, 4)?; t.send(Key::Enter)?; @@ -217,13 +243,25 @@ fn a_resize_extends_the_stops_and_keeps_the_ones_it_had() -> termlens::Result<() /// column and nothing about the escape handling would be at fault. #[test] fn a_table_laid_out_with_its_own_stops_lines_up() -> termlens::Result<()> { - let mut t = sh(concat!( + let mut t = emit(&[ // Stops at columns 5 and 13, one-based, and nothing else. - r"printf '\033[3g\033[5G\033H\033[13G\033H\033[1G'; ", - r"printf 'id\011name\011role\r\n'; ", - r"printf '7\011ada\011dev\r\n'; ", - "printf DONE; read _" - ))?; + "--csi", + "3g", + "--csi", + "5G", + "--esc", + "H", + "--csi", + "13G", + "--esc", + "H", + "--csi", + "1G", + "id\tname\trole\r\n", + "7\tada\tdev\r\n", + "DONE", + "--wait", + ])?; t.wait_until(|s| s.contains("DONE"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "id name role", "{s}"); diff --git a/crates/termlens/tests/timeouts.rs b/crates/termlens/tests/timeouts.rs index 7a27616..6ecc75a 100644 --- a/crates/termlens/tests/timeouts.rs +++ b/crates/termlens/tests/timeouts.rs @@ -5,21 +5,31 @@ use std::time::{Duration, Instant}; use termlens::{Error, Key, Terminal}; +mod common; + /// The builder default is deliberately far too short for the app; only -/// the per-call override can see each wait through. -fn slow_app(script: &str) -> Terminal { - Terminal::builder() - .size(80, 24) - .env_clear() - .timeout(Duration::from_millis(150)) - .args(["-c", script]) - .spawn("/bin/sh") - .expect("spawn") +/// the per-call override can see each wait through. Steps are documented +/// in `fixtures/emit/src/main.rs`. +fn slow_app(steps: &[&str]) -> Terminal { + common::spawn_emit( + Terminal::builder() + .size(80, 24) + .env_clear() + .timeout(Duration::from_millis(150)), + steps, + ) + .expect("spawn") } #[test] fn wait_frame_for_overrides_the_builder_default() -> termlens::Result<()> { - let mut t = slow_app(r"sleep 1; printf '\033[?2026hlate frame\033[?2026l'; read guard"); + let mut t = slow_app(&[ + "--sleep", + "1s", + "--raw", + r"\e[?2026hlate frame\e[?2026l", + "--wait", + ]); t.wait_frame_for(|s| s.contains("late frame"), Duration::from_secs(30))?; t.send(Key::Enter)?; assert!(t.wait_exit_for(Duration::from_secs(30))?.success()); @@ -28,7 +38,7 @@ fn wait_frame_for_overrides_the_builder_default() -> termlens::Result<()> { #[test] fn wait_idle_for_overrides_the_builder_default() -> termlens::Result<()> { - let mut t = slow_app(r"printf busy; read guard"); + let mut t = slow_app(&["busy", "--wait"]); // A 200ms quiet period cannot be observed under the 150ms builder // deadline at all — the wait would expire before the silence does. let start = Instant::now(); @@ -52,7 +62,7 @@ fn wait_idle_for_overrides_the_builder_default() -> termlens::Result<()> { #[test] fn wait_exit_for_overrides_the_builder_default() -> termlens::Result<()> { - let mut t = slow_app("sleep 1; exit 3"); + let mut t = slow_app(&["--sleep", "1s", "--exit", "3"]); let status = t.wait_exit_for(Duration::from_secs(30))?; assert_eq!(status.code(), Some(3), "status: {status}"); Ok(()) @@ -62,13 +72,14 @@ fn wait_exit_for_overrides_the_builder_default() -> termlens::Result<()> { /// reports the deadline that actually applied — not the builder's. #[test] fn per_call_timeouts_report_their_own_deadline() { - let mut t = Terminal::builder() - .size(80, 24) - .env_clear() - .timeout(Duration::from_secs(30)) - .args(["-c", "read guard"]) - .spawn("/bin/sh") - .expect("spawn"); + let mut t = common::spawn_emit( + Terminal::builder() + .size(80, 24) + .env_clear() + .timeout(Duration::from_secs(30)), + &["--wait"], + ) + .expect("spawn"); for (label, err) in [ ( @@ -100,12 +111,13 @@ fn per_call_timeouts_report_their_own_deadline() { /// The overrides must not cost wall-clock when they are not needed. #[test] fn a_short_per_call_timeout_fails_fast() { - let mut t = Terminal::builder() - .env_clear() - .timeout(Duration::from_secs(60)) - .args(["-c", "read guard"]) - .spawn("/bin/sh") - .expect("spawn"); + let mut t = common::spawn_emit( + Terminal::builder() + .env_clear() + .timeout(Duration::from_secs(60)), + &["--wait"], + ) + .expect("spawn"); let start = Instant::now(); let _ = t.wait_frame_for(|s| s.contains("never"), Duration::from_millis(100)); assert!( diff --git a/crates/termlens/tests/utf8.rs b/crates/termlens/tests/utf8.rs index 2bfc555..1e5f33a 100644 --- a/crates/termlens/tests/utf8.rs +++ b/crates/termlens/tests/utf8.rs @@ -5,11 +5,12 @@ use std::time::Duration; use termlens::{Key, Terminal}; -fn sh(script: &str) -> termlens::Result { - Terminal::builder() - .timeout(Duration::from_secs(5)) - .args(["-c", script]) - .spawn("/bin/sh") +mod common; + +/// The `emit` fixture; `--raw` is what carries a byte that is not UTF-8. +/// Steps are documented in `fixtures/emit/src/main.rs`. +fn emit(steps: &[&str]) -> termlens::Result { + common::spawn_emit(Terminal::builder().timeout(Duration::from_secs(5)), steps) } #[test] @@ -17,7 +18,7 @@ fn an_invalid_byte_is_a_replacement_character_and_the_columns_hold() -> termlens // A Latin-1 `é` in a file name, a corrupted log line: the byte used to // be deleted from the grid, so `done` sat one column too far left and // nothing on the Screen said a byte had gone. - let mut t = sh(r"printf 'raw: caf\351 done'; read guard")?; + let mut t = emit(&["--raw", r"raw: caf\xe9 done", "--wait"])?; t.wait_until(|s| s.contains("done"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "raw: caf\u{FFFD} done"); @@ -32,7 +33,15 @@ fn an_invalid_byte_is_a_replacement_character_and_the_columns_hold() -> termlens fn a_character_split_across_two_writes_is_still_one_character() -> termlens::Result<()> { // 汉 is E6 B1 89. The lead byte arrives in one read and the rest in // another; the sanitizer must carry it, not replace it. - let mut t = sh(r"printf 'ab\346'; sleep 0.2; printf '\261\211cd'; read guard")?; + let mut t = emit(&[ + "--raw", + r"ab\xe6", + "--sleep", + "200ms", + "--raw", + r"\xb1\x89cd", + "--wait", + ])?; t.wait_until(|s| s.contains("cd"))?; let s = t.screen(); assert_eq!(s.row_text(0).trim_end(), "ab汉cd"); @@ -48,7 +57,15 @@ fn wait_idle_does_not_call_a_half_written_character_silence() -> termlens::Resul // not idleness — the stream ends mid-character — so `wait_idle` must // hold until the character completes, and the screen it returns to must // show it whole. - let mut t = sh(r"printf 'ab\346'; sleep 0.6; printf '\261\211cd'; read guard")?; + let mut t = emit(&[ + "--raw", + r"ab\xe6", + "--sleep", + "600ms", + "--raw", + r"\xb1\x89cd", + "--wait", + ])?; t.wait_until(|s| s.contains("ab"))?; t.wait_idle_for(Duration::from_millis(150), Duration::from_secs(5))?; let s = t.screen(); diff --git a/fixtures/emit/Cargo.toml b/fixtures/emit/Cargo.toml new file mode 100644 index 0000000..fd8cd9a --- /dev/null +++ b/fixtures/emit/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "emit" +description = "termlens fixture: writes exactly the bytes its arguments describe, then waits, sleeps or exits as told — the program under test where a shell script used to be" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +# No dependencies, on purpose: see the crate-level doc. +[dependencies] + +[lints] +workspace = true diff --git a/fixtures/emit/src/main.rs b/fixtures/emit/src/main.rs new file mode 100644 index 0000000..92ea7e5 --- /dev/null +++ b/fixtures/emit/src/main.rs @@ -0,0 +1,256 @@ +//! termlens fixture: writes exactly the bytes its arguments describe, in +//! order, then waits, sleeps or exits as told — the program under test +//! wherever the suite used to run `sh -c 'printf …; read _'` (#249). +//! +//! A shell was a variable in every test that was not about the shell: which +//! `sh` runs decides how `printf` reads `\033`, what `read` does at EOF, how +//! fast a loop spins — and whether there is a shell at all. This is the same +//! program on every platform, with an argument per step and no quoting to +//! decode. Steps apply left to right: +//! +//! ```text +//! TEXT literal text — any argument that is not a step below +//! NL CR a newline / a carriage return +//! --text WORD literal text that happens to spell a step name +//! --esc BYTES ESC followed by BYTES `--esc '(0'` is ESC ( 0 +//! --csi BYTES ESC [ followed by BYTES `--csi '?2026h'` +//! --raw SPEC bytes with escapes: \e \n \r \t \a \\ and \xNN +//! --sleep DUR pause for DUR: `250ms`, `1.5s`, `2s` +//! --wait read one line from stdin and discard it — "hold the +//! terminal open until the test sends Enter" +//! --echo-line read one line from stdin and write it back, without +//! its newline +//! --echo copy stdin to stdout, line by line, until EOF +//! --seq N the integers 1..=N, one per line +//! --cwd the current directory, as the process sees it +//! --pid this process's id, in decimal +//! --env NAME the value of environment variable NAME, or `unset` +//! --environ every environment variable as NAME=VALUE, one per +//! line, sorted +//! --exit CODE exit now with CODE +//! --loop run the steps before it once, then the steps after it +//! forever +//! ``` +//! +//! Every emitting step is one `write_all` and a flush, so a test that wants +//! two writes says so with two steps. `--wait` at EOF exits 0: a harness that +//! closed the terminal has finished with it. +//! +//! Fixture rules: **no timing but the explicit `--sleep`, and std only.** A +//! dependency would make this a second thing the suite tests; a clock would +//! make it a second source of flakiness. Nothing here reads the terminal's +//! modes or sets them — a step that needs raw mode is a different fixture. + +use std::io::{self, BufRead, Write}; +use std::process; +use std::time::Duration; + +#[derive(Debug, Clone)] +enum Step { + Write(Vec), + Sleep(Duration), + Wait, + EchoLine, + Echo, + Seq(u64), + Cwd, + Pid, + Env(String), + Environ, + Exit(i32), +} + +fn usage(reason: &str) -> ! { + eprintln!("emit: {reason}"); + eprintln!("see the crate doc in fixtures/emit/src/main.rs for the steps"); + process::exit(2) +} + +/// Decode `--raw`: `\e` `\n` `\r` `\t` `\a` `\\` and `\xNN`; every other +/// byte is itself. A lone or malformed escape is an error, not a guess. +fn raw(spec: &str) -> Vec { + let mut out = Vec::with_capacity(spec.len()); + let mut chars = spec.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + continue; + } + match chars.next() { + Some('e') => out.push(0x1b), + Some('n') => out.push(b'\n'), + Some('r') => out.push(b'\r'), + Some('t') => out.push(b'\t'), + Some('a') => out.push(0x07), + Some('\\') => out.push(b'\\'), + Some('x') => { + let hex: String = chars.by_ref().take(2).collect(); + match u8::from_str_radix(&hex, 16) { + Ok(b) if hex.len() == 2 => out.push(b), + _ => usage(&format!("--raw: `\\x{hex}` is not two hex digits")), + } + } + other => usage(&format!( + "--raw: unknown escape `\\{}`", + other.map_or(String::new(), String::from) + )), + } + } + out +} + +/// `250ms`, `1.5s`, `2s`. +fn duration(spec: &str) -> Duration { + let (number, unit) = spec.strip_suffix("ms").map_or_else( + || (spec.strip_suffix('s').unwrap_or(spec), 1.0), + |n| (n, 0.001), + ); + match number.parse::() { + Ok(v) if v.is_finite() && v >= 0.0 => Duration::from_secs_f64(v * unit), + _ => usage(&format!( + "--sleep: `{spec}` is not a duration like 250ms or 1.5s" + )), + } +} + +/// The steps, and the index the forever-loop starts at, if there is one. +fn parse(args: impl Iterator) -> (Vec, Option) { + let mut steps = Vec::new(); + let mut loop_from = None; + let mut args = args; + while let Some(arg) = args.next() { + let mut next = |flag: &str| { + args.next() + .unwrap_or_else(|| usage(&format!("{flag} needs a value"))) + }; + let step = match arg.as_str() { + "NL" => Step::Write(b"\n".to_vec()), + "CR" => Step::Write(b"\r".to_vec()), + "--text" => Step::Write(next("--text").into_bytes()), + "--esc" => { + let mut b = vec![0x1b]; + b.extend_from_slice(next("--esc").as_bytes()); + Step::Write(b) + } + "--csi" => { + let mut b = b"\x1b[".to_vec(); + b.extend_from_slice(next("--csi").as_bytes()); + Step::Write(b) + } + "--raw" => Step::Write(raw(&next("--raw"))), + "--sleep" => Step::Sleep(duration(&next("--sleep"))), + "--wait" => Step::Wait, + "--echo-line" => Step::EchoLine, + "--echo" => Step::Echo, + "--seq" => Step::Seq( + next("--seq") + .parse() + .unwrap_or_else(|_| usage("--seq needs a count")), + ), + "--cwd" => Step::Cwd, + "--pid" => Step::Pid, + "--env" => Step::Env(next("--env")), + "--environ" => Step::Environ, + "--exit" => Step::Exit( + next("--exit") + .parse() + .unwrap_or_else(|_| usage("--exit needs an exit code")), + ), + "--loop" => { + loop_from = Some(steps.len()); + continue; + } + other if other.starts_with("--") => usage(&format!("unknown step `{other}`")), + text => Step::Write(text.as_bytes().to_vec()), + }; + steps.push(step); + } + (steps, loop_from) +} + +/// Read one line from stdin. `None` at EOF — the terminal is gone. +fn line(stdin: &mut impl BufRead) -> io::Result> { + let mut s = String::new(); + if stdin.read_line(&mut s)? == 0 { + return Ok(None); + } + while s.ends_with('\n') || s.ends_with('\r') { + s.pop(); + } + Ok(Some(s)) +} + +fn run(steps: &[Step], out: &mut impl Write, stdin: &mut impl BufRead) -> io::Result<()> { + for step in steps { + match step { + Step::Write(bytes) => out.write_all(bytes)?, + Step::Sleep(d) => std::thread::sleep(*d), + Step::Wait => { + if line(stdin)?.is_none() { + process::exit(0); + } + } + Step::EchoLine => match line(stdin)? { + Some(l) => out.write_all(l.as_bytes())?, + None => process::exit(0), + }, + Step::Echo => { + let mut buf = String::new(); + while stdin.read_line(&mut buf)? > 0 { + out.write_all(buf.as_bytes())?; + out.flush()?; + buf.clear(); + } + } + Step::Seq(n) => { + for i in 1..=*n { + writeln!(out, "{i}")?; + } + } + Step::Cwd => { + let dir = std::env::current_dir()?; + out.write_all(dir.to_string_lossy().as_bytes())?; + } + Step::Pid => write!(out, "{}", process::id())?, + Step::Env(name) => match std::env::var_os(name) { + Some(value) => out.write_all(value.to_string_lossy().as_bytes())?, + None => out.write_all(b"unset")?, + }, + Step::Environ => { + let mut vars: Vec = std::env::vars_os() + .map(|(k, v)| format!("{}={}", k.to_string_lossy(), v.to_string_lossy())) + .collect(); + vars.sort(); + for var in vars { + writeln!(out, "{var}")?; + } + } + Step::Exit(code) => { + out.flush()?; + process::exit(*code); + } + } + out.flush()?; + } + Ok(()) +} + +fn main() { + let (steps, loop_from) = parse(std::env::args().skip(1)); + let stdout = io::stdout(); + let mut out = stdout.lock(); + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + // A write that fails is the terminal going away under us — the harness + // has torn down. Nothing to report to, so nothing to report. + let result = match loop_from { + Some(from) => run(&steps[..from], &mut out, &mut stdin).and_then(|()| loop { + run(&steps[from..], &mut out, &mut stdin)?; + }), + None => run(&steps, &mut out, &mut stdin), + }; + if result.is_err() { + process::exit(0); + } +}