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
This commit is contained in:
ddidderr committed 2026-10-12 06:02:28 +02:00
1 parent a714fd9a65
commit 42cb8796da
7 files changed
+1567 -12

No files matched your search

+426
View File
@@ -0,0 +1,426 @@
#![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
);
}