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 b3145ad7c1
commit 84f665cdfb
7 files changed
+1567 -12

No files matched your search

@@ -0,0 +1,204 @@
//! A mock `org.freedesktop.Flatpak` session bus service implementing the
//! `org.freedesktop.Flatpak.Development` interface (HostCommand,
//! HostCommandSignal, HostCommandExited), used by the Flatpak host command
//! integration tests. It really runs the requested command locally (with the
//! received stdio fds), so the whole path can be observed end to end.
//!
//! Shared by `ghostty-os` and `ghostty-termio` tests via `#[path]`.
#![allow(dead_code, clippy::unwrap_used, clippy::needless_pass_by_value)]
use std::{
collections::HashMap,
ffi::OsStr,
os::{
fd::OwnedFd,
unix::{
ffi::OsStrExt,
process::{CommandExt, ExitStatusExt},
},
},
process::{Command, Stdio},
sync::{Arc, Mutex},
};
use zbus::{blocking::Connection, zvariant};
pub const BUS_NAME: &str = "org.freedesktop.Flatpak";
pub const OBJECT_PATH: &str = "/org/freedesktop/Flatpak/Development";
pub const INTERFACE: &str = "org.freedesktop.Flatpak.Development";
/// What the mock saw.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spawned {
pub pid: u32,
pub argv: Vec<String>,
pub cwd: String,
pub env: HashMap<String, String>,
pub fds: Vec<u32>,
}
#[derive(Default)]
pub struct Record {
pub spawned: Vec<Spawned>,
/// (pid, signal, to_process_group)
pub signals: Vec<(u32, u32, bool)>,
}
pub struct Service {
pub record: Arc<Mutex<Record>>,
// Keeps the bus name and the object server alive.
_conn: Connection,
}
struct Dev {
record: Arc<Mutex<Record>>,
conn: Connection,
}
#[zbus::interface(name = "org.freedesktop.Flatpak.Development")]
impl Dev {
fn host_command(
&self,
cwd_path: Vec<u8>,
argv: Vec<Vec<u8>>,
fds: HashMap<u32, zvariant::OwnedFd>,
envs: HashMap<String, String>,
_flags: u32,
) -> zbus::fdo::Result<u32> {
let strip = |v: &[u8]| -> String {
let v = v.strip_suffix(&[0]).unwrap_or(v);
String::from_utf8_lossy(v).into_owned()
};
let argv: Vec<String> = argv.iter().map(|a| strip(a)).collect();
let cwd = strip(&cwd_path);
let fd_keys: Vec<u32> = {
let mut k: Vec<u32> = fds.keys().copied().collect();
k.sort_unstable();
k
};
let mut fds = fds;
let mut take = |i: u32| -> Stdio {
fds.remove(&i)
.map_or_else(Stdio::null, |fd| Stdio::from(OwnedFd::from(fd)))
};
let mut cmd = Command::new(OsStr::from_bytes(argv[0].as_bytes()));
cmd.args(&argv[1..])
.env_clear()
.envs(&envs)
.current_dir(&cwd)
.stdin(take(0))
.stdout(take(1))
.stderr(take(2))
// The real service runs the command in its own process group
// so that `to_process_group` signals reach the whole group.
.process_group(0);
let mut child = cmd
.spawn()
.map_err(|e| zbus::fdo::Error::Failed(format!("spawn {}: {e}", argv[0])))?;
let pid = child.id();
self.record.lock().unwrap().spawned.push(Spawned {
pid,
argv,
cwd,
env: envs,
fds: fd_keys,
});
// Report the exit like the portal: raw wait status.
let conn = self.conn.clone();
std::thread::spawn(move || {
let status = child.wait().unwrap();
let _ = conn.emit_signal(
None::<&str>,
OBJECT_PATH,
INTERFACE,
"HostCommandExited",
&(pid, status.into_raw() as u32),
);
});
Ok(pid)
}
fn host_command_signal(&self, pid: u32, signal: u32, to_process_group: bool) {
self.record
.lock()
.unwrap()
.signals
.push((pid, signal, to_process_group));
let (Some(rpid), Some(sig)) = (
rustix::process::Pid::from_raw(pid as i32),
rustix::process::Signal::from_named_raw(signal as i32),
) else {
return;
};
// The child leads its own group (see above).
let _ = if to_process_group {
rustix::process::kill_process_group(rpid, sig)
} else {
rustix::process::kill_process(rpid, sig)
};
}
}
impl Service {
/// Claim `org.freedesktop.Flatpak` on the session bus.
pub fn start() -> Self {
let record = Arc::new(Mutex::new(Record::default()));
let conn = zbus::blocking::connection::Builder::session()
.unwrap()
.name(BUS_NAME)
.unwrap()
.build()
.unwrap();
let dev = Dev {
record: Arc::clone(&record),
conn: conn.clone(),
};
conn.object_server().at(OBJECT_PATH, dev).unwrap();
Self {
record,
_conn: conn,
}
}
pub fn signals(&self) -> Vec<(u32, u32, bool)> {
self.record.lock().unwrap().signals.clone()
}
pub fn spawned(&self) -> Vec<Spawned> {
self.record.lock().unwrap().spawned.clone()
}
}
/// Run `test_name` of the current test binary under `dbus-run-session`
/// (once; the inner run sees `GHOSTTY_FLATPAK_MOCK_INNER=1`). Returns true if
/// the caller is the inner process and should run the real test body.
/// Returns false when the outer process ran the inner one (and asserted it
/// passed) or when `dbus-run-session` is unavailable.
pub fn under_dbus_session(test_name: &str) -> bool {
if std::env::var_os("GHOSTTY_FLATPAK_MOCK_INNER").is_some() {
return true;
}
let exe = std::env::current_exe().unwrap();
let out = match Command::new("dbus-run-session")
.arg("--")
.arg(exe)
.args(["--exact", test_name, "--nocapture", "--test-threads=1"])
.env("GHOSTTY_FLATPAK_MOCK_INNER", "1")
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("dbus-run-session unavailable ({e}); skipping {test_name}");
return false;
}
};
assert!(
out.status.success(),
"inner test failed\n--- stdout\n{}\n--- stderr\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
false
}