Files
ghostty-rs/tests/gtk_replay.rs
T
ddidderr 42cb8796da test: verify IME, clipboard dialogs, quick terminal and Flatpak paths
Exercises the runtime paths that had never run (details in
docs/rewrite/notes/verification-VR.md):
- IME: replay steps drive the surface's real GtkIMContext handlers with
  a real preedit + attributes; preedit renders under the cursor and
  commits reach the pty, incl. dead-key and compose orderings.
- Paste and OSC 52 read/write confirmation dialogs: allow, deny and
  remember flows.
- Quick terminal on niri via wlr-layer-shell: positions, sizes, toggle
  and autohide. Fixes a real bug: link order could put libwayland-client
  ahead of libgtk4-layer-shell, silently disabling the quick terminal;
  apprt-gtk now loads libwayland-client via wayland-sys dlopen.
- Flatpak host commands against a zbus mock of
  org.freedesktop.Flatpak.Development under dbus-run-session: spawn,
  exit status, cwd/env/fd forwarding and SIGHUP on stop, both directly
  and through termio Exec (flatpak-info path seam added).
- Horizontal-scroll tab switching thresholds and reset.

tests/gtk_replay.rs runs the real binary against the session display
(skips without one).

Test Plan:
- cargo test --test gtk_replay (needs a Wayland/X11 display)
- cargo test -p ghostty-os --test flatpak_mock; -p ghostty-termio
  --test flatpak_exec
2026-10-12 06:02:28 +02:00

427 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).
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
);
}