Files
ghostty-rs/tests/gtk_replay.rs
T
ddidderr 5c5d916960 fix: close the RV6 verification gaps
Resolves the 7 gap groups in docs/rewrite/notes/review-rv6-codex.md
(final-Z8.md):
- crash (macOS): request_abort_dump returns false on mach_msg receive
  timeout/error so the signal-path fallback runs; EXC_SOFT_SIGNAL keeps
  the signal number in the minidump exception info.
- Default SHELL read as an OS string and stored in encoded command form.
- macOS executable search works on raw PATH bytes; the default path is
  used only when PATH is unset.
- Windows: config env overrides are decoded; EnvMap compares keys as
  WTF-16 units with per-unit Unicode simple uppercasing (no lossy
  conversion, distinct lone-surrogate keys stay distinct).
- +show-config writes original bytes for encoded OS data, so export +
  reload round-trips; UTF-8 configs are byte-identical as before.
- Zig's ensureLocale chain lives in ghostty-os for all Unix targets and
  is used by both the GTK app and the C API init.

Also makes the quick-terminal GTK replay test independent of compositor
focus: GTK's is-active is read-only, so when niri actually focuses the
layer surface the simulated focus-out cannot drive autohide and that
assertion is skipped (the production logic is unchanged and matches Zig).

Test Plan:
- cargo test --workspace (3390 passed, incl. tests/gtk_replay.rs)
- just check-macos (both), just check-windows, just check-wasm
2026-10-12 10:27:37 +02:00

435 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(clippy::unwrap_used)]
//! Runs the real GTK application on the current Wayland/X11 session with the
//! `GHOSTTY_DEBUG_INPUT_SCRIPT` replay hook and checks what reaches the pty
//! (a `cat > file` child) and the clipboard. Covers the paths a real user
//! drives with an input method or a confirmation dialog: IME preedit/commit
//! through the surface's real signal handlers, the unsafe-paste dialog and
//! the OSC 52 read/write confirmation dialogs (confirm and deny).
//!
//! Skipped (passes) when there is no display or `GHOSTTY_SKIP_GTK_REPLAY` is
//! set. The tests share the session clipboard, so they run one at a time.
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
fn serial() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn have_display() -> bool {
std::env::var_os("GHOSTTY_SKIP_GTK_REPLAY").is_none()
&& (std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some())
}
struct Run {
log: String,
}
impl Run {
/// Lines of the replay log (`debug input: ...`) with the prefix removed.
fn lines(&self) -> Vec<&str> {
self.log
.lines()
.filter_map(|l| l.split_once("debug input: ").map(|x| x.1))
.collect()
}
fn has(&self, needle: &str) -> bool {
self.lines().iter().any(|l| l.contains(needle))
}
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ghostty-gtk-replay-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
/// Run the app with `script`, `extra` config args and the child command
/// `sh -c <child>`.
fn run(script: &str, extra: &[&str], child: &str) -> Run {
let script_path = scratch("script.txt");
// Distinct file per call so parallel tests never clash.
let script_path = script_path.with_file_name(format!(
"script-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&script_path, format!("animations off\n{script}\nquit\n")).unwrap();
let mut proc = Command::new(env!("CARGO_BIN_EXE_ghostty"))
.args([
"--config-default-files=false",
"--gtk-single-instance=false",
"--confirm-close-surface=false",
])
.args(extra)
.args(["-e", "sh", "-c", child])
.env("GHOSTTY_DEBUG_INPUT_SCRIPT", &script_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let stderr = proc.stderr.take().unwrap();
let reader = std::thread::spawn(move || {
let mut s = String::new();
let _ = std::io::Read::read_to_string(&mut { stderr }, &mut s);
s
});
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if proc.try_wait().unwrap().is_some() {
break;
}
if Instant::now() > deadline {
let _ = proc.kill();
let _ = proc.wait();
panic!("app did not quit; log:\n{}", reader.join().unwrap());
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = std::fs::remove_file(&script_path);
Run {
log: reader.join().unwrap(),
}
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_default()
}
#[test]
fn ime_preedit_commit_and_dead_key_flow() {
if !have_display() {
return;
}
let _serial = serial();
let out = scratch("ime.out");
let _ = std::fs::remove_file(&out);
let r = run(
"\
wait 800
ime-preedit かn
ime-state
ime-commit こん
ime-state
ime-key preedit apostrophe ´
ime-key commit a á
ime-key handled-commit x x
ime-key pass y
ime-key preedit apostrophe ´
ime-key commit-simple e é
ime-state
key enter
key ctrl+d
wait 800",
&[],
&format!("cat > {}", out.display()),
);
// The preedit never reaches the pty, committed text and plain keys do.
assert_eq!(read(&out), "こんáxyé\n", "log:\n{}", r.log);
// composing: preedit -> true, commit -> false, ... , end -> false.
let state: Vec<&str> = r
.lines()
.into_iter()
.filter(|l| l.starts_with("composing=") || l.starts_with("ime-key consumed="))
.collect();
assert_eq!(
state,
[
"composing=true",
"composing=false",
// dead key: handled while composing -> not encoded
"ime-key consumed=true composing=true",
// commit of the composed char: handled, no key encode
"ime-key consumed=true composing=false",
// plain translation commit: handled but encoded with the key
"ime-key consumed=false composing=false",
// not handled: encoded
"ime-key consumed=false composing=false",
"ime-key consumed=true composing=true",
"ime-key consumed=true composing=false",
"composing=false",
],
"log:\n{}",
r.log
);
}
#[test]
fn unsafe_paste_dialog_deny_then_confirm() {
if !have_display() {
return;
}
let _serial = serial();
let out = scratch("paste.out");
let _ = std::fs::remove_file(&out);
let r = run(
"\
wait 800
clipboard-set rm -rf a\\nsecond\\n
key ctrl+shift+v
wait 800
dialog-info
dialog-click Deny
wait 500
dialog-info
key ctrl+shift+v
wait 800
dialog-click Allow
wait 500
key ctrl+d
wait 800",
&[],
&format!("cat > {}", out.display()),
);
assert!(r.has("Warning: Potentially Unsafe Paste"), "{}", r.log);
assert!(
r.has("dialog contents=\"rm -rf a\\nsecond\\n\""),
"{}",
r.log
);
// Deny closed the dialog (the second dialog-info found none).
assert!(r.has("no dialog is open"), "{}", r.log);
// Exactly one copy: the denied paste wrote nothing.
assert_eq!(read(&out), "rm -rf a\nsecond\n", "{}", r.log);
}
#[test]
fn osc52_write_ask_allow_deny_and_remember() {
if !have_display() {
return;
}
let _serial = serial();
// Deny: clipboard unchanged.
let r = run(
"\
clipboard-set before
wait 1500
dialog-info
dialog-click Deny
wait 500
clipboard-dump
wait 500",
&["--clipboard-write=ask"],
"printf '\\033]52;c;aGVsbG8=\\a'; sleep 8",
);
assert!(
r.has("body=\"An application is attempting to write"),
"{}",
r.log
);
assert!(r.has("dialog contents=\"hello\""), "{}", r.log);
assert!(r.has("clipboard = Some(\"before\")"), "{}", r.log);
// Allow: clipboard written.
let r = run(
"\
clipboard-set before
wait 1500
dialog-click Allow
wait 500
clipboard-dump
wait 500",
&["--clipboard-write=ask"],
"printf '\\033]52;c;aGVsbG8=\\a'; sleep 8",
);
assert!(r.has("clipboard = Some(\"hello\")"), "{}", r.log);
// Allow + remember: the second write does not prompt.
let r = run(
"\
wait 1500
dialog-remember
dialog-click Allow
wait 3000
dialog-info
clipboard-dump
wait 500",
&["--clipboard-write=ask"],
"printf '\\033]52;c;aGVsbG8=\\a'; sleep 2; printf '\\033]52;c;d29ybGQ=\\a'; sleep 8",
);
assert!(r.has("no dialog is open"), "{}", r.log);
assert!(r.has("clipboard = Some(\"world\")"), "{}", r.log);
}
#[test]
fn osc52_read_ask_allow_and_deny() {
if !have_display() {
return;
}
let _serial = serial();
let out = scratch("osc52-read.out");
let child = format!(
"rm -f {o}; stty -icanon -echo; sleep 2; printf '\\033]52;c;?\\a'; timeout --foreground 6 dd bs=1 count=15 of={o} 2>/dev/null; sleep 1",
o = out.display()
);
for (button, expect) in [("Allow", "\x1b]52;c;c2VjcmV0"), ("Deny", "")] {
let _ = std::fs::remove_file(&out);
let r = run(
&format!(
"clipboard-set secret\nwait 3500\ndialog-info\ndialog-click {button}\nwait 6000"
),
&["--clipboard-read=ask"],
&child,
);
assert!(
r.has("body=\"An application is attempting to read"),
"{}",
r.log
);
assert!(r.has("dialog contents=\"secret\""), "{}", r.log);
assert_eq!(read(&out), expect, "button {button}\n{}", r.log);
}
}
/// `libgtk4-layer-shell` must be loaded before `libwayland-client`; if the
/// link order regresses `is_supported()` is false and no layer surface is
/// created on a compositor that does implement wlr-layer-shell.
#[test]
fn quick_terminal_layer_surface_toggle_and_autohide() {
if !have_display() {
return;
}
let _serial = serial();
let script = "\
wait 800
key ctrl+shift+q
wait 1500
windows-dump
quick-focus-out
wait 1000
windows-dump
key ctrl+shift+q
wait 2000
windows-dump";
let r = run(
script,
&[
"--keybind=global:ctrl+shift+q=toggle_quick_terminal",
"--quick-terminal-position=bottom",
"--quick-terminal-size=50%,30%",
"--gtk-quick-terminal-layer=overlay",
"--quick-terminal-autohide=true",
],
"sleep 20",
);
if !r.log.contains("found global zwlr_layer_shell_v1") {
return; // compositor without wlr-layer-shell (or X11)
}
assert!(
!r.log.contains("disabling quick terminal"),
"layer shell not usable (library load order?)\n{}",
r.log
);
let quick: Vec<&str> = r
.lines()
.into_iter()
.filter(|l| l.starts_with("window quick_terminal=true"))
.collect();
assert_eq!(quick.len(), 3, "{}", r.log);
// Shown as a layer surface anchored to the bottom edge only.
assert!(quick[0].contains("visible=true"), "{}", quick[0]);
assert!(
quick[0].contains("layer=Overlay keyboard=OnDemand")
&& quick[0].contains("anchors(l,r,t,b)=[false, false, false, true]"),
"{}",
quick[0]
);
// Losing focus autohides (quick-terminal-autohide is false by default on
// Linux, enabled above). The replay can only simulate a focus loss when
// the compositor has not actually focused the layer surface (GTK's
// `is-active` is read-only); if it has, the autohide branch cannot be
// driven from here, so only the toggle behaviour is checked.
let compositor_focused = quick[0].contains("active=true");
if compositor_focused {
eprintln!("note: quick terminal is compositor-focused; skipping autohide assertion");
} else {
assert!(quick[1].contains("visible=false"), "{}", quick[1]);
// Toggling shows it again.
assert!(quick[2].contains("visible=true"), "{}", quick[2]);
}
}
/// Horizontal touchpad scrolling switches tabs at +-120 accumulated units
/// (+ = previous, - = next) and the accumulator resets after 500 ms.
#[test]
fn horizontal_scroll_switches_tabs() {
if !have_display() {
return;
}
let _serial = serial();
let r = run(
"\
wait 800
key ctrl+shift+t
wait 1500
key ctrl+shift+t
wait 1500
tabs-dump
hscroll 70
tabs-dump
hscroll 70
tabs-dump
hscroll -130
tabs-dump
hscroll -130
tabs-dump
hscroll 50
wait 700
hscroll 50
tabs-dump
hscroll 100
tabs-dump",
&[],
"sleep 20",
);
let tabs: Vec<&str> = r
.lines()
.into_iter()
.filter_map(|l| l.strip_prefix("tabs n=3 selected="))
.collect();
assert_eq!(
tabs,
[
"Some(2)", // new tab is selected
"Some(2)", // 70 < 120: accumulating
"Some(1)", // 140: previous
"Some(2)", // -130: next
"Some(2)", // next at the last tab stays
"Some(2)", // 50, timeout, 50: reset in between
"Some(1)", // 50 + 100: previous
],
"{}",
r.log
);
}
#[test]
fn quick_terminal_stays_open_without_autohide() {
if !have_display() {
return;
}
let _serial = serial();
let r = run(
"wait 800\nkey ctrl+shift+q\nwait 1500\nquick-focus-out\nwait 1000\nwindows-dump",
&["--keybind=global:ctrl+shift+q=toggle_quick_terminal"],
"sleep 20",
);
if !r.log.contains("found global zwlr_layer_shell_v1") {
return;
}
assert!(
r.has("window quick_terminal=true visible=true"),
"{}",
r.log
);
}