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

+19 -3
View File
@@ -17,7 +17,23 @@ const INTERFACE: &str = "org.freedesktop.Flatpak.Development";
/// Returns true if we're running in a Flatpak environment.
pub fn is_flatpak() -> bool {
std::path::Path::new("/.flatpak-info").exists()
std::path::Path::new(
FLATPAK_INFO_OVERRIDE
.get()
.map_or(std::path::Path::new(FLATPAK_INFO), |p| p.as_path()),
)
.exists()
}
const FLATPAK_INFO: &str = "/.flatpak-info";
static FLATPAK_INFO_OVERRIDE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Injection seam for integration tests: make [`is_flatpak`] look at `path`
/// instead of `/.flatpak-info`. Can be set once per process; returns false if
/// it was already set. Not used by the application.
pub fn set_flatpak_info_path(path: impl Into<std::path::PathBuf>) -> bool {
FLATPAK_INFO_OVERRIDE.set(path.into()).is_ok()
}
/// Errors that are possible from us.
@@ -76,8 +92,8 @@ impl Shared {
/// pipes, /dev/null, etc.) but this was purpose built for a specific need so
/// it doesn't have all of those.
///
/// NOTE: this can't be exercised in the test environment (needs a Flatpak
/// session bus with the Development interface); it is a best-effort port.
/// The DBus path is exercised against a mock `org.freedesktop.Flatpak`
/// service in `tests/flatpak_mock.rs` (run under `dbus-run-session`).
pub struct FlatpakHostCommand {
/// Argv are the arguments to call on the host with argv[0] being
/// the command to execute.
@@ -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
}
+130
View File
@@ -0,0 +1,130 @@
#![cfg(unix)]
#![allow(clippy::unwrap_used)]
//! `FlatpakHostCommand` against a mock `org.freedesktop.Flatpak.Development`
//! DBus service on a private session bus (`dbus-run-session`).
mod common {
pub mod flatpak_mock;
}
use std::{
io::Read,
os::fd::OwnedFd,
time::{Duration, Instant},
};
use common::flatpak_mock::Service;
use ghostty_os::flatpak::{self, Error, FlatpakHostCommand};
fn devnull() -> OwnedFd {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/null")
.unwrap()
.into()
}
#[test]
fn host_command_spawn_exit_status_and_signal() {
if !common::flatpak_mock::under_dbus_session("host_command_spawn_exit_status_and_signal") {
return;
}
// The injection seam: pretend /.flatpak-info exists.
let info = std::env::temp_dir().join(format!("flatpak-info-{}", std::process::id()));
std::fs::write(&info, "[Application]\nname=com.mitchellh.ghostty\n").unwrap();
assert!(flatpak::set_flatpak_info_path(&info));
assert!(flatpak::is_flatpak());
let svc = Service::start();
// 1. Spawn returns the host pid; stdout goes through the passed fd;
// cwd and env arrive; the exit status is propagated.
{
let (r, w) = ghostty_os::pipe::pipe().unwrap();
let mut cmd = FlatpakHostCommand::new(
vec![
"/bin/sh".into(),
"-c".into(),
"echo \"$FOO:$(pwd)\"; exit 7".into(),
],
devnull(),
w,
devnull(),
);
cmd.cwd = Some("/".into());
let mut env = ghostty_os::env::EnvMap::default();
env.insert("FOO", "bar");
cmd.env = Some(env);
let pid = cmd.spawn().unwrap();
assert!(pid > 1);
assert_eq!(cmd.wait(), Ok(7));
// wait() resets the state: a second wait is an error.
assert_eq!(cmd.wait(), Err(Error::FlatpakMustBeStarted));
drop(cmd); // closes our write end so the read below sees EOF
let mut out = String::new();
std::fs::File::from(r).read_to_string(&mut out).unwrap();
assert_eq!(out, "bar:/\n");
let spawned = svc.spawned();
assert_eq!(spawned.len(), 1);
assert_eq!(spawned[0].pid, pid);
assert_eq!(spawned[0].argv[0], "/bin/sh");
assert_eq!(spawned[0].cwd, "/");
assert_eq!(spawned[0].env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(spawned[0].fds, vec![0, 1, 2]);
}
// 2. Exit code 0 and the async wait path.
{
let mut cmd = FlatpakHostCommand::new(
vec!["/bin/sh".into(), "-c".into(), ":".into()],
devnull(),
devnull(),
devnull(),
);
cmd.cwd = Some("/".into());
cmd.spawn().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap();
assert_eq!(rx.recv_timeout(Duration::from_secs(10)).unwrap(), Ok(0));
}
// 3. Spawn failure on the host is reported.
{
let mut cmd = FlatpakHostCommand::new(
vec!["/nonexistent/binary".into()],
devnull(),
devnull(),
devnull(),
);
cmd.cwd = Some("/".into());
assert_eq!(cmd.spawn(), Err(Error::FlatpakSpawnFail));
}
// 4. SIGHUP to the process group (what Exec::stop sends) reaches the
// host command, which then exits; the signal handle works after the
// command moved into the exit watcher.
{
let mut cmd = FlatpakHostCommand::new(
vec!["/bin/sleep".into(), "30".into()],
devnull(),
devnull(),
devnull(),
);
cmd.cwd = Some("/".into());
let pid = cmd.spawn().unwrap();
let handle = cmd.signal_handle();
let (tx, rx) = std::sync::mpsc::channel();
cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap();
let start = Instant::now();
assert_eq!(handle.signal(1, true), Ok(()));
let r = rx.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(r.is_ok(), "{r:?}");
assert!(start.elapsed() < Duration::from_secs(10));
assert_eq!(svc.signals(), vec![(pid, 1, true)]);
}
let _ = std::fs::remove_file(&info);
}