Windows was previously only compile-checked. With Wine 11 (staging) and mingw available, scripts/windows-wine-test.sh (`just test-windows-wine`) builds expat/FreeType/fontconfig for mingw under target/wine-deps and runs the test suites of ghostty-vt, unicode, datastruct, input, os, config, terminal, crash, termio and cli under Wine (~2,700 tests). New termio/tests/exec_windows.rs drives real cmd.exe children over ConPTY through Termio (output, exit codes, typing, 120 KiB writes, stop, start failure). Fixes found: Windows paths from fs::canonicalize leaked the \\?\ prefix (new ghostty_os::path::realpath strips it like Zig); ghostty-font compiles hb-ft for bundled HarfBuzz on Windows; ~25 tests had Unix assumptions. Wine limitations are ignored with reasons (ResizePseudoConsole stub, no POSIX rename over existing files) - docs/rewrite/notes/windows-wine.md. +list-themes preview parity (former KNOWN_DIFFERENCES rows 5-9): the search box ports vaxis TextInput (word navigation and editing bindings, grapheme-wise editing), ASCII-only case folding, light initial colors until terminal reports arrive (no COLORFGBG), and load errors end the preview with exit 1; PTY tests compare with /usr/bin/ghostty. Test Plan: - just test-windows-wine - cargo test -p ghostty-cli --test list_themes_pty - cargo test --workspace --no-fail-fast (3601 passed)
383 lines
12 KiB
Rust
383 lines
12 KiB
Rust
//! PTY-driven checks of the interactive `+list-themes` preview against the
|
|
//! upstream Ghostty binary (`/usr/bin/ghostty`, skipped when absent): the
|
|
//! same keystrokes must leave the same text on the screen.
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use std::{
|
|
fmt::Write as _,
|
|
io::{Read, Write},
|
|
os::fd::OwnedFd,
|
|
path::Path,
|
|
process::{Command, Stdio},
|
|
sync::mpsc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use ghostty_terminal::terminal::{Options, Terminal};
|
|
use rustix::{
|
|
fs::{Mode, OFlags, open},
|
|
pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt},
|
|
termios::{Winsize, tcsetwinsize},
|
|
};
|
|
|
|
const ORACLE: &str = "/usr/bin/ghostty";
|
|
const COLS: u16 = 100;
|
|
const ROWS: u16 = 30;
|
|
|
|
/// A spawned `+list-themes` session in a PTY.
|
|
struct Session {
|
|
child: std::process::Child,
|
|
master: std::fs::File,
|
|
rx: mpsc::Receiver<Vec<u8>>,
|
|
output: Vec<u8>,
|
|
/// Answer the primary device attributes query (ends vaxis' startup query).
|
|
reply_da: bool,
|
|
answered: usize,
|
|
}
|
|
|
|
impl Session {
|
|
fn spawn(exe: &str, envs: &[(&str, &str)], reply_da: bool) -> Session {
|
|
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY).unwrap();
|
|
grantpt(&master).unwrap();
|
|
unlockpt(&master).unwrap();
|
|
let name = ptsname(&master, Vec::new()).unwrap();
|
|
let slave: OwnedFd = open(
|
|
name.as_c_str(),
|
|
OFlags::RDWR | OFlags::NOCTTY,
|
|
Mode::empty(),
|
|
)
|
|
.unwrap();
|
|
tcsetwinsize(
|
|
&master,
|
|
Winsize {
|
|
ws_row: ROWS,
|
|
ws_col: COLS,
|
|
ws_xpixel: 0,
|
|
ws_ypixel: 0,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let mut cmd = Command::new("setsid");
|
|
cmd.args(["--ctty", exe, "+list-themes"])
|
|
.env("TERM", "xterm-256color")
|
|
.env("GHOSTTY_RESOURCES_DIR", "/usr/share/ghostty")
|
|
.env_remove("COLORFGBG")
|
|
.stdin(Stdio::from(slave.try_clone().unwrap()))
|
|
.stdout(Stdio::from(slave.try_clone().unwrap()))
|
|
.stderr(Stdio::from(slave));
|
|
for (k, v) in envs {
|
|
cmd.env(k, v);
|
|
}
|
|
let child = cmd.spawn().unwrap();
|
|
let master = std::fs::File::from(master);
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut reader = master.try_clone().unwrap();
|
|
std::thread::spawn(move || {
|
|
let mut buf = [0u8; 8192];
|
|
while let Ok(n) = reader.read(&mut buf) {
|
|
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
Session {
|
|
child,
|
|
master,
|
|
rx,
|
|
output: vec![],
|
|
reply_da,
|
|
answered: 0,
|
|
}
|
|
}
|
|
|
|
/// Collect output until the program has been quiet for `quiet`.
|
|
fn settle(&mut self, quiet: Duration) {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while Instant::now() < deadline {
|
|
match self.rx.recv_timeout(quiet) {
|
|
Ok(chunk) => {
|
|
self.output.extend_from_slice(&chunk);
|
|
if self.reply_da {
|
|
let needle = b"\x1b[c";
|
|
let count = self
|
|
.output
|
|
.windows(needle.len())
|
|
.filter(|w| *w == needle)
|
|
.count();
|
|
while self.answered < count {
|
|
self.answered += 1;
|
|
self.master.write_all(b"\x1b[?62;4c").unwrap();
|
|
}
|
|
}
|
|
}
|
|
Err(_) => return,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn send(&mut self, bytes: &[u8]) {
|
|
self.master.write_all(bytes).unwrap();
|
|
self.settle(Duration::from_millis(500));
|
|
}
|
|
|
|
/// The screen text after replaying everything received so far.
|
|
fn screen(&self) -> Terminal {
|
|
let mut t = Terminal::new(Options::new(COLS, ROWS)).unwrap();
|
|
t.vt_stream().next_slice(&self.output).unwrap();
|
|
t
|
|
}
|
|
|
|
fn finish(mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
fn rows_of(t: &Terminal) -> Vec<String> {
|
|
t.plain_string()
|
|
.unwrap()
|
|
.lines()
|
|
.map(|l| l.trim_end().to_owned())
|
|
.collect()
|
|
}
|
|
|
|
fn have_oracle() -> bool {
|
|
Path::new(ORACLE).exists()
|
|
}
|
|
|
|
fn rust_exe() -> &'static str {
|
|
env!("CARGO_BIN_EXE_ghostty")
|
|
}
|
|
|
|
/// Type `keys` in search mode and return the rows of the screen.
|
|
fn search_screen(exe: &str, envs: &[(&str, &str)], keys: &[&[u8]]) -> Vec<String> {
|
|
let mut s = Session::spawn(exe, envs, true);
|
|
s.settle(Duration::from_millis(1500));
|
|
s.send(b"/");
|
|
for k in keys {
|
|
s.send(k);
|
|
}
|
|
let rows = rows_of(&s.screen());
|
|
s.finish();
|
|
rows
|
|
}
|
|
|
|
/// The text inside the search box (the row below the top border).
|
|
fn search_box_text(rows: &[String]) -> String {
|
|
let i = rows.iter().rposition(|r| r.contains('\u{256d}')).unwrap();
|
|
// The box starts at column 20; its content follows the left border.
|
|
let inner: String = rows[i + 1].chars().skip(21).collect();
|
|
inner
|
|
.trim_end()
|
|
.trim_end_matches('\u{2502}')
|
|
.trim_end()
|
|
.to_owned()
|
|
}
|
|
|
|
/// Run a script of key presses in search mode and return the search box
|
|
/// text after each one.
|
|
fn search_trace(exe: &str, envs: &[(&str, &str)], steps: &[&[u8]]) -> Vec<String> {
|
|
let mut s = Session::spawn(exe, envs, true);
|
|
s.settle(Duration::from_millis(1500));
|
|
s.send(b"/");
|
|
let mut out = vec![];
|
|
for k in steps {
|
|
s.send(k);
|
|
out.push(search_box_text(&rows_of(&s.screen())));
|
|
}
|
|
s.finish();
|
|
out
|
|
}
|
|
|
|
/// Compare Rust against the oracle (when present) and against `expected`.
|
|
fn check_trace(steps: &[&[u8]], expected: &[&str]) {
|
|
let rust = search_trace(rust_exe(), &[], steps);
|
|
if have_oracle() {
|
|
let zig = search_trace(ORACLE, &[], steps);
|
|
assert_eq!(expected, zig.as_slice(), "oracle");
|
|
}
|
|
assert_eq!(expected, rust.as_slice(), "rust");
|
|
}
|
|
|
|
#[test]
|
|
fn word_navigation_and_edit_bindings_match_upstream() {
|
|
check_trace(
|
|
&[
|
|
b"blue red green", // type
|
|
b"\x1bb", // Alt+B
|
|
b"X",
|
|
b"\x1b[1;3D", // Alt+Left
|
|
b"Y",
|
|
b"\x1bf", // Alt+F
|
|
b"Z",
|
|
b"\x1b[1;3C", // Alt+Right
|
|
b"W",
|
|
b"\x17", // Ctrl+W
|
|
b"\x1b\x7f", // Alt+Backspace
|
|
b"\x01", // Ctrl+A
|
|
b"\x1bd", // Alt+D
|
|
b"\x1bd",
|
|
b"\x05\x0b", // Ctrl+E, Ctrl+K (no-op at end)
|
|
b"\x01\x0b", // Ctrl+A, Ctrl+K
|
|
b"ab cd",
|
|
b"\x02\x02\x04", // Ctrl+B x2, Ctrl+D
|
|
b"\x15", // Ctrl+U
|
|
],
|
|
&[
|
|
"blue red green",
|
|
"blue red green",
|
|
"blue red Xgreen",
|
|
"blue red Xgreen",
|
|
"blue red YXgreen",
|
|
"blue red YXgreen",
|
|
"blue red YXgreenZ",
|
|
"blue red YXgreenZ",
|
|
"blue red YXgreenZW",
|
|
"blue red",
|
|
"blue",
|
|
"blue",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"ab cd",
|
|
"ab d",
|
|
"d",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_delete_and_cursor_move_by_grapheme() {
|
|
let cluster = "n\u{303}\u{323}";
|
|
let typed = format!("a{cluster}b");
|
|
check_trace(
|
|
&[
|
|
"xe\u{301}".as_bytes(),
|
|
b"\x7f", // Backspace removes e + U+0301
|
|
b"\x15",
|
|
typed.as_bytes(),
|
|
b"\x1b[D\x1b[D", // Left x2: before the ZWJ cluster
|
|
b"\x1b[3~", // Delete removes the whole cluster
|
|
],
|
|
&["xe\u{301}", "x", "", &typed, &typed, "ab"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_folds_only_ascii_case() {
|
|
let cfg = tempfile::tempdir().unwrap();
|
|
let themes = cfg.path().join("ghostty/themes");
|
|
std::fs::create_dir_all(&themes).unwrap();
|
|
std::fs::write(themes.join("\u{c4}rger"), "background = #101010\n").unwrap();
|
|
std::fs::write(themes.join("zzplain"), "background = #101010\n").unwrap();
|
|
let envs = [("XDG_CONFIG_HOME", cfg.path().to_str().unwrap())];
|
|
// `Ä` matches the theme `Ärger` (exact bytes), `ä` does not (no
|
|
// Unicode folding of the theme name or the query).
|
|
for exe in [rust_exe(), ORACLE] {
|
|
if exe == ORACLE && !have_oracle() {
|
|
continue;
|
|
}
|
|
let upper = search_screen(exe, &envs, &["\u{c4}".as_bytes()]);
|
|
assert!(
|
|
upper.iter().any(|r| r.contains("\u{c4}rger")),
|
|
"{exe}: {upper:?}"
|
|
);
|
|
let lower = search_screen(exe, &envs, &["\u{e4}".as_bytes()]);
|
|
assert!(
|
|
!lower.iter().any(|r| r.contains("\u{c4}rger")),
|
|
"{exe}: {lower:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The (fg, bg) style of every cell of the active screen, as text.
|
|
fn style_grid(t: &Terminal) -> Vec<String> {
|
|
use ghostty_terminal::{
|
|
page_list::Pin,
|
|
point::{Coordinate, Point},
|
|
};
|
|
let screen = t.screens.active();
|
|
let pages = &screen.pages;
|
|
let mut out = vec![];
|
|
for y in 0..pages.rows {
|
|
let pin = pages
|
|
.pin(Point::Active(Coordinate {
|
|
x: 0,
|
|
y: u32::from(y),
|
|
}))
|
|
.unwrap();
|
|
let mut row = String::new();
|
|
for x in 0..pages.cols {
|
|
let st = Pin { x, ..pin }.style(pages).unwrap();
|
|
let _ = write!(row, "{:?}/{:?};", st.fg_color, st.bg_color);
|
|
}
|
|
out.push(row);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn initial_styles(exe: &str, envs: &[(&str, &str)]) -> Vec<String> {
|
|
// No terminal replies of any kind: the scheme stays at its initial value.
|
|
let mut s = Session::spawn(exe, envs, false);
|
|
s.settle(Duration::from_millis(3000));
|
|
let grid = style_grid(&s.screen());
|
|
s.finish();
|
|
grid
|
|
}
|
|
|
|
#[test]
|
|
fn initial_colors_are_light_and_ignore_colorfgbg() {
|
|
let plain = initial_styles(rust_exe(), &[]);
|
|
let dark_hint = initial_styles(rust_exe(), &[("COLORFGBG", "15;0")]);
|
|
assert_eq!(plain, dark_hint);
|
|
if have_oracle() {
|
|
let zig = initial_styles(ORACLE, &[("COLORFGBG", "15;0")]);
|
|
assert_eq!(zig, plain);
|
|
}
|
|
// Light: black on white (rgb) somewhere on screen.
|
|
let all = plain.join("");
|
|
assert!(all.contains("Rgb"), "{all}");
|
|
}
|
|
|
|
/// Run the preview with an unreadable user theme; returns the exit status
|
|
/// (if the program exited by itself) and the final screen text.
|
|
fn unreadable_theme_run(exe: &str) -> (Option<i32>, String) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let cfg = tempfile::tempdir().unwrap();
|
|
let themes = cfg.path().join("ghostty/themes");
|
|
std::fs::create_dir_all(&themes).unwrap();
|
|
let bad = themes.join("unreadable");
|
|
std::fs::write(&bad, "background = #101010\n").unwrap();
|
|
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o000)).unwrap();
|
|
let envs = [("XDG_CONFIG_HOME", cfg.path().to_str().unwrap())];
|
|
let mut s = Session::spawn(exe, &envs, true);
|
|
s.settle(Duration::from_millis(2000));
|
|
let _ = s.master.write_all(b"/a");
|
|
s.settle(Duration::from_millis(1000));
|
|
// Give a loaded machine time to reach (and exit from) the failure.
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
let mut status = s.child.try_wait().unwrap();
|
|
while status.is_none() && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
status = s.child.try_wait().unwrap();
|
|
}
|
|
s.settle(Duration::from_millis(300));
|
|
let code = status.and_then(|st| st.code());
|
|
let text = rows_of(&s.screen()).join("\n");
|
|
s.finish();
|
|
(code, text)
|
|
}
|
|
|
|
#[test]
|
|
fn load_file_error_exits_the_preview() {
|
|
let (code, text) = unreadable_theme_run(rust_exe());
|
|
let _ = text;
|
|
if have_oracle() {
|
|
let (zcode, ztext) = unreadable_theme_run(ORACLE);
|
|
let _ = ztext;
|
|
assert_eq!(zcode.is_some(), code.is_some());
|
|
}
|
|
assert!(code.is_some(), "the preview must have exited");
|
|
}
|