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:
1 parent
a714fd9a65
commit
42cb8796da
7 files changed
+1567
-12
No files matched your search
@@ -24,11 +24,25 @@
|
||||
//! click 1 [mods] # press+release; repeat quickly for multi-click
|
||||
//! drag 10 10 200 10 [mods] # move, press left, move in steps, release
|
||||
//! scroll 0 -3 # wheel (dx dy, in ticks)
|
||||
//! hscroll 130 # horizontal touchpad scroll (surface units); +-120 switches tabs
|
||||
//! tabs-dump # log the tab count and selected tab of the active window
|
||||
//! resize 600 400 # set the window default size (logical px)
|
||||
//! clipboard-set a\nb # set the GDK clipboard (\n and \\ escapes)
|
||||
//! window-screenshot /abs/out.png # GTK render of the whole window (dialogs included)
|
||||
//! windows-dump # log every toplevel window / dialog
|
||||
//! clipboard-dump # log the GDK clipboard and primary text
|
||||
//! ime-preedit ´a # IM: preedit-start (if needed) + preedit-changed with this preedit
|
||||
//! ime-preedit-end # IM: preedit-end
|
||||
//! ime-commit á # IM: commit signal (outside any key event)
|
||||
//! ime-key <mode> <key> [text] # key press+release through the IM filter, mode:
|
||||
//! # preedit | commit | commit-simple | handled-commit | pass
|
||||
//! ime-state # log whether the surface is composing
|
||||
//! animations off # disable GTK/libadwaita animations (so dialogs render at once)
|
||||
//! quick-focus-out # emit notify::is-active on the quick terminal window (it is not
|
||||
//! # compositor-focused here), running the autohide handler
|
||||
//! dialog-info # log the open AdwAlertDialog (heading, body, responses, contents)
|
||||
//! dialog-remember # turn on the "remember choice" switch of the open dialog
|
||||
//! dialog-click Allow # click the dialog button with this label (Allow/Deny/OK/Cancel...)
|
||||
//! quit # quit the application
|
||||
//! ```
|
||||
//!
|
||||
@@ -38,9 +52,14 @@
|
||||
|
||||
use std::{cell::RefCell, rc::Rc, time::Duration};
|
||||
|
||||
use gtk::{gdk, glib, glib::translate::IntoGlib, prelude::*};
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib, glib::translate::IntoGlib};
|
||||
|
||||
use crate::class::{application::Application, surface::Surface, window::Window};
|
||||
use crate::class::{
|
||||
application::Application,
|
||||
surface::{DebugIm, DebugImKey, Surface},
|
||||
window::Window,
|
||||
};
|
||||
|
||||
/// One parsed command.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -58,11 +77,23 @@ pub enum Step {
|
||||
Click(u32, String),
|
||||
Drag(f64, f64, f64, f64, String),
|
||||
Scroll(f64, f64),
|
||||
HScroll(f64),
|
||||
TabsDump,
|
||||
ClipboardDump,
|
||||
Resize(i32, i32),
|
||||
ClipboardSet(String),
|
||||
WindowsDump,
|
||||
WindowScreenshot(String),
|
||||
ImePreedit(String),
|
||||
ImePreeditEnd,
|
||||
ImeCommit(String),
|
||||
ImeKey(String, String, String),
|
||||
ImeState,
|
||||
Animations(bool),
|
||||
DialogInfo,
|
||||
QuickFocusOut,
|
||||
DialogRemember,
|
||||
DialogClick(String),
|
||||
Quit,
|
||||
}
|
||||
|
||||
@@ -149,6 +180,8 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
|
||||
};
|
||||
Step::Scroll(dx, dy)
|
||||
}
|
||||
"hscroll" => Step::HScroll(rest.parse().map_err(|_| err("hscroll needs dx"))?),
|
||||
"tabs-dump" => Step::TabsDump,
|
||||
"clipboard-dump" => Step::ClipboardDump,
|
||||
"windows-dump" => Step::WindowsDump,
|
||||
"window-screenshot" if !rest.is_empty() => Step::WindowScreenshot(rest.to_owned()),
|
||||
@@ -164,6 +197,38 @@ pub fn parse(script: &str) -> Result<Vec<Step>, String> {
|
||||
};
|
||||
Step::Resize(w, h)
|
||||
}
|
||||
"ime-preedit" if !rest.is_empty() => Step::ImePreedit(rest.to_owned()),
|
||||
"ime-preedit-end" => Step::ImePreeditEnd,
|
||||
"ime-commit" if !rest.is_empty() => Step::ImeCommit(rest.to_owned()),
|
||||
"ime-state" => Step::ImeState,
|
||||
"animations" => Step::Animations(match rest {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err(err("animations needs on|off")),
|
||||
}),
|
||||
"dialog-info" => Step::DialogInfo,
|
||||
"quick-focus-out" => Step::QuickFocusOut,
|
||||
"dialog-remember" => Step::DialogRemember,
|
||||
"dialog-click" if !rest.is_empty() => Step::DialogClick(rest.to_owned()),
|
||||
"ime-key" => {
|
||||
let mut it = rest.splitn(3, char::is_whitespace);
|
||||
let (Some(mode), Some(key)) = (it.next(), it.next()) else {
|
||||
return Err(err("ime-key needs <mode> <key> [text]"));
|
||||
};
|
||||
if ![
|
||||
"preedit",
|
||||
"commit",
|
||||
"commit-simple",
|
||||
"handled-commit",
|
||||
"pass",
|
||||
]
|
||||
.contains(&mode)
|
||||
{
|
||||
return Err(err("bad ime-key mode"));
|
||||
}
|
||||
let text = it.next().unwrap_or("").trim_start().to_owned();
|
||||
Step::ImeKey(mode.to_owned(), key.to_owned(), text)
|
||||
}
|
||||
"quit" => Step::Quit,
|
||||
_ => return Err(err(&format!("bad command {line:?}"))),
|
||||
});
|
||||
@@ -240,6 +305,7 @@ pub fn start(app: &Application) {
|
||||
steps,
|
||||
next: RefCell::new(0),
|
||||
pos: std::cell::Cell::new((0.0, 0.0)),
|
||||
mock_im: mock_im::MockIm::default(),
|
||||
});
|
||||
// Wait for the first surface to be ready (realized, core initialized).
|
||||
glib::timeout_add_local(Duration::from_millis(100), move || {
|
||||
@@ -258,13 +324,15 @@ struct Runner {
|
||||
next: RefCell<usize>,
|
||||
/// Last pointer position (logical px).
|
||||
pos: std::cell::Cell<(f64, f64)>,
|
||||
/// Stands in for the input method context as the signal emitter.
|
||||
mock_im: mock_im::MockIm,
|
||||
}
|
||||
|
||||
/// Render the whole widget tree of `win` (GL area content may be blank, but
|
||||
/// dialogs and popovers are included) to a PNG.
|
||||
fn window_screenshot(win: >k::Window, path: &str) {
|
||||
fn window_screenshot(win: &impl IsA<gtk::Widget>, path: &str) {
|
||||
let (w, h) = (win.width(), win.height());
|
||||
let paintable = gtk::WidgetPaintable::new(Some(win));
|
||||
let paintable = gtk::WidgetPaintable::new(Some(win.as_ref()));
|
||||
let snapshot = gtk::Snapshot::new();
|
||||
paintable.snapshot(
|
||||
snapshot.upcast_ref::<gdk::Snapshot>(),
|
||||
@@ -301,6 +369,64 @@ fn dump_tree(w: >k::Widget, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log what the compositor-facing state of a window is (layer-shell role for
|
||||
/// the quick terminal).
|
||||
fn dump_window_state(win: &Window) {
|
||||
use gtk4_layer_shell::LayerShell;
|
||||
let gw: >k::Window = win.upcast_ref();
|
||||
let layer = gw.is_layer_window().then(|| {
|
||||
let edges = [
|
||||
gtk4_layer_shell::Edge::Left,
|
||||
gtk4_layer_shell::Edge::Right,
|
||||
gtk4_layer_shell::Edge::Top,
|
||||
gtk4_layer_shell::Edge::Bottom,
|
||||
]
|
||||
.map(|e| gw.is_anchor(e));
|
||||
format!(
|
||||
"layer={:?} keyboard={:?} namespace={:?} anchors(l,r,t,b)={edges:?} exclusive={}",
|
||||
gw.layer(),
|
||||
gw.keyboard_mode(),
|
||||
gw.namespace(),
|
||||
gw.exclusive_zone()
|
||||
)
|
||||
});
|
||||
log::warn!(
|
||||
"debug input: window quick_terminal={} visible={} mapped={} active={} size={}x{} layer_shell={:?}",
|
||||
win.is_quick_terminal(),
|
||||
gw.is_visible(),
|
||||
gw.is_mapped(),
|
||||
gw.is_active(),
|
||||
gw.width(),
|
||||
gw.height(),
|
||||
layer
|
||||
);
|
||||
}
|
||||
|
||||
fn collect_descendants<T: IsA<gtk::Widget>>(w: >k::Widget, out: &mut Vec<T>) {
|
||||
let mut c = w.first_child();
|
||||
while let Some(child) = c {
|
||||
if let Ok(t) = child.clone().downcast::<T>() {
|
||||
out.push(t);
|
||||
}
|
||||
collect_descendants(&child, out);
|
||||
c = child.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
fn find_descendant<T: IsA<gtk::Widget>>(w: >k::Widget) -> Option<T> {
|
||||
if let Ok(t) = w.clone().downcast::<T>() {
|
||||
return Some(t);
|
||||
}
|
||||
let mut c = w.first_child();
|
||||
while let Some(child) = c {
|
||||
if let Some(t) = find_descendant(&child) {
|
||||
return Some(t);
|
||||
}
|
||||
c = child.next_sibling();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_mods(spec: &str) -> gdk::ModifierType {
|
||||
let mut state = gdk::ModifierType::empty();
|
||||
for m in spec.split('+').filter(|m| !m.is_empty()) {
|
||||
@@ -325,12 +451,29 @@ fn button_mask(button: u32) -> gdk::ModifierType {
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
/// The window the replay acts on: the application's active window, or,
|
||||
/// if that is hidden (e.g. an autohidden quick terminal that GTK still
|
||||
/// reports as active), the first visible Ghostty window.
|
||||
fn active_window(&self) -> Option<Window> {
|
||||
let active = self
|
||||
.app
|
||||
.active_window()
|
||||
.and_then(|w| w.downcast::<Window>().ok());
|
||||
if active
|
||||
.as_ref()
|
||||
.is_some_and(gtk::prelude::WidgetExt::is_visible)
|
||||
{
|
||||
return active;
|
||||
}
|
||||
gtk::Window::list_toplevels()
|
||||
.into_iter()
|
||||
.filter_map(|w| w.downcast::<Window>().ok())
|
||||
.find(gtk::prelude::WidgetExt::is_visible)
|
||||
.or(active)
|
||||
}
|
||||
|
||||
fn active_surface(&self) -> Option<Surface> {
|
||||
self.app
|
||||
.active_window()?
|
||||
.downcast::<Window>()
|
||||
.ok()?
|
||||
.get_active_surface()
|
||||
self.active_window()?.get_active_surface()
|
||||
}
|
||||
|
||||
/// Run steps until the next `wait`, which reschedules us.
|
||||
@@ -386,6 +529,22 @@ impl Runner {
|
||||
s.debug_scroll(dy);
|
||||
}
|
||||
}
|
||||
Step::HScroll(dx) => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
s.debug_hscroll(dx);
|
||||
}
|
||||
}
|
||||
Step::TabsDump => {
|
||||
if let Some(win) = self
|
||||
.app
|
||||
.active_window()
|
||||
.and_then(|w| w.downcast::<Window>().ok())
|
||||
{
|
||||
let tv = win.tab_view();
|
||||
let selected = tv.selected_page().map(|p| tv.page_position(&p));
|
||||
log::warn!("debug input: tabs n={} selected={selected:?}", tv.n_pages());
|
||||
}
|
||||
}
|
||||
Step::Resize(w, h) => {
|
||||
if let Some(win) = self.app.active_window() {
|
||||
win.set_default_size(w, h);
|
||||
@@ -406,6 +565,9 @@ impl Runner {
|
||||
dump_tree(win.upcast_ref(), 0);
|
||||
}
|
||||
for w in gtk::Window::list_toplevels() {
|
||||
if let Some(win) = w.downcast_ref::<Window>() {
|
||||
dump_window_state(win);
|
||||
}
|
||||
log::warn!(
|
||||
"debug input: toplevel {} title={:?} visible={}",
|
||||
w.type_().name(),
|
||||
@@ -420,6 +582,61 @@ impl Runner {
|
||||
s.debug_clipboard_dump();
|
||||
}
|
||||
}
|
||||
Step::ImePreedit(text) => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
self.mock_im.set_preedit(&text);
|
||||
if !s.debug_im_composing() {
|
||||
s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditStart);
|
||||
}
|
||||
s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditChanged);
|
||||
}
|
||||
}
|
||||
Step::ImePreeditEnd => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
self.mock_im.set_preedit("");
|
||||
s.debug_im(self.mock_im.upcast_ref(), DebugIm::PreeditEnd);
|
||||
}
|
||||
}
|
||||
Step::ImeCommit(text) => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
s.debug_im(self.mock_im.upcast_ref(), DebugIm::Commit(text));
|
||||
}
|
||||
}
|
||||
Step::ImeState => {
|
||||
if let Some(s) = self.active_surface() {
|
||||
log::warn!("debug input: composing={}", s.debug_im_composing());
|
||||
}
|
||||
}
|
||||
Step::Animations(on) => {
|
||||
if let Some(settings) = gtk::Settings::default() {
|
||||
settings.set_gtk_enable_animations(on);
|
||||
}
|
||||
}
|
||||
Step::DialogInfo => self.dialog_info(),
|
||||
Step::QuickFocusOut => {
|
||||
let quick = gtk::Window::list_toplevels()
|
||||
.into_iter()
|
||||
.filter_map(|w| w.downcast::<Window>().ok())
|
||||
.find(Window::is_quick_terminal);
|
||||
match quick {
|
||||
// The window is not active, so this is exactly what
|
||||
// GTK emits when a quick terminal loses focus.
|
||||
Some(w) => w.notify("is-active"),
|
||||
None => log::warn!("debug input: no quick terminal window"),
|
||||
}
|
||||
}
|
||||
Step::DialogRemember => match self.find_dialog() {
|
||||
Some(d) => {
|
||||
if let Some(row) = find_descendant::<adw::SwitchRow>(d.upcast_ref()) {
|
||||
row.set_active(true);
|
||||
} else {
|
||||
log::warn!("debug input: the dialog has no switch");
|
||||
}
|
||||
}
|
||||
None => log::warn!("debug input: no dialog is open"),
|
||||
},
|
||||
Step::DialogClick(label) => self.dialog_click(&label),
|
||||
Step::ImeKey(mode, spec, text) => self.ime_key(&mode, &spec, &text),
|
||||
Step::Text(text) => match self.active_surface() {
|
||||
Some(s) => s.debug_commit_text(&text),
|
||||
None => log::warn!("debug input: no active surface"),
|
||||
@@ -468,6 +685,122 @@ impl Runner {
|
||||
s.debug_mouse_button(press, button, x, y, m);
|
||||
}
|
||||
|
||||
/// The open alert dialog of the active window, if any.
|
||||
fn find_dialog(&self) -> Option<adw::AlertDialog> {
|
||||
find_descendant::<adw::AlertDialog>(self.app.active_window()?.upcast_ref())
|
||||
}
|
||||
|
||||
fn dialog_info(&self) {
|
||||
let Some(d) = self.find_dialog() else {
|
||||
log::warn!("debug input: no dialog is open");
|
||||
return;
|
||||
};
|
||||
log::warn!(
|
||||
"debug input: dialog {} heading={:?} body={:?} remember_switch={}",
|
||||
d.type_().name(),
|
||||
d.heading(),
|
||||
d.body(),
|
||||
find_descendant::<adw::SwitchRow>(d.upcast_ref()).is_some()
|
||||
);
|
||||
let mut chain = Vec::new();
|
||||
let mut w = Some(d.clone().upcast::<gtk::Widget>());
|
||||
while let Some(x) = w {
|
||||
chain.push(format!("{}(mapped={})", x.type_().name(), x.is_mapped()));
|
||||
w = x.parent();
|
||||
}
|
||||
log::warn!(
|
||||
"debug input: dialog visible={} realized={} chain={}",
|
||||
d.is_visible(),
|
||||
d.is_realized(),
|
||||
chain.join(" < ")
|
||||
);
|
||||
for id in ["cancel", "ok", "close", "confirm", "deny"] {
|
||||
if d.has_response(id) {
|
||||
log::warn!(
|
||||
"debug input: dialog response {id} label={:?}",
|
||||
d.response_label(id)
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(view) = find_descendant::<gtk::TextView>(d.upcast_ref()) {
|
||||
let buf = view.buffer();
|
||||
log::warn!(
|
||||
"debug input: dialog contents={:?}",
|
||||
buf.text(&buf.start_iter(), &buf.end_iter(), false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Click the dialog's button labelled `label` (the real GtkButton, so the
|
||||
/// AdwAlertDialog response machinery runs as for a user click).
|
||||
fn dialog_click(&self, label: &str) {
|
||||
let Some(d) = self.find_dialog() else {
|
||||
log::warn!("debug input: no dialog is open");
|
||||
return;
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
collect_descendants::<gtk::Button>(d.upcast_ref(), &mut buttons);
|
||||
match buttons
|
||||
.iter()
|
||||
.find(|b| b.label().is_some_and(|l| l.eq_ignore_ascii_case(label)))
|
||||
{
|
||||
Some(b) => b.emit_clicked(),
|
||||
None => log::warn!(
|
||||
"debug input: no dialog button {label:?}; have {:?}",
|
||||
buttons.iter().map(gtk::Button::label).collect::<Vec<_>>()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn ime_key(&self, mode: &str, spec: &str, text: &str) {
|
||||
let Some(surface) = self.active_surface() else {
|
||||
log::warn!("debug input: no active surface");
|
||||
return;
|
||||
};
|
||||
let (key, state) = match parse_key(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::error!("debug input: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(keycode) = surface
|
||||
.display()
|
||||
.map_keyval(key)
|
||||
.and_then(|keys| keys.first().map(gdk::KeymapKey::keycode))
|
||||
else {
|
||||
log::error!("debug input: no keycode for {spec:?}");
|
||||
return;
|
||||
};
|
||||
let sim = match mode {
|
||||
"preedit" => {
|
||||
self.mock_im.set_preedit(text);
|
||||
DebugImKey::Preedit
|
||||
}
|
||||
"commit" => {
|
||||
self.mock_im.set_preedit("");
|
||||
DebugImKey::Commit(text.to_owned())
|
||||
}
|
||||
"commit-simple" => {
|
||||
self.mock_im.set_preedit("");
|
||||
DebugImKey::CommitSimple(text.to_owned())
|
||||
}
|
||||
"handled-commit" => DebugImKey::HandledCommit(text.to_owned()),
|
||||
_ => DebugImKey::Pass,
|
||||
};
|
||||
let consumed = surface.debug_im_key(
|
||||
self.mock_im.upcast_ref(),
|
||||
&sim,
|
||||
key.into_glib(),
|
||||
keycode,
|
||||
state,
|
||||
);
|
||||
log::warn!(
|
||||
"debug input: ime-key consumed={consumed} composing={}",
|
||||
surface.debug_im_composing()
|
||||
);
|
||||
}
|
||||
|
||||
fn key(&self, spec: &str) {
|
||||
let Some(surface) = self.active_surface() else {
|
||||
log::warn!("debug input: no active surface");
|
||||
@@ -496,6 +829,64 @@ impl Runner {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stand-in `GtkIMContext` whose preedit string and attributes the replay
|
||||
/// controls; the surface's real signal handlers read it through
|
||||
/// `preedit_string()` exactly as they would from an input method.
|
||||
pub mod mock_im {
|
||||
use gtk::{glib, subclass::prelude::*};
|
||||
|
||||
mod imp {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use gtk::{glib, pango, subclass::prelude::*};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockIm {
|
||||
pub preedit: RefCell<String>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for MockIm {
|
||||
const NAME: &'static str = "GhosttyDebugMockIm";
|
||||
type Type = super::MockIm;
|
||||
type ParentType = gtk::IMContext;
|
||||
}
|
||||
|
||||
impl ObjectImpl for MockIm {}
|
||||
|
||||
impl IMContextImpl for MockIm {
|
||||
fn preedit_string(&self) -> (glib::GString, pango::AttrList, i32) {
|
||||
let text = self.preedit.borrow().clone();
|
||||
// What a real input method sends: an underline over the
|
||||
// whole preedit and the cursor at its end.
|
||||
let attrs = pango::AttrList::new();
|
||||
let mut underline = pango::AttrInt::new_underline(pango::Underline::Single);
|
||||
underline.set_start_index(0);
|
||||
underline.set_end_index(text.len() as u32);
|
||||
attrs.insert(underline);
|
||||
let cursor = text.chars().count() as i32;
|
||||
(text.as_str().into(), attrs, cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct MockIm(ObjectSubclass<imp::MockIm>) @extends gtk::IMContext;
|
||||
}
|
||||
|
||||
impl Default for MockIm {
|
||||
fn default() -> Self {
|
||||
glib::Object::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockIm {
|
||||
pub fn set_preedit(&self, text: &str) {
|
||||
self.imp().preedit.replace(text.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -520,6 +911,33 @@ mod tests {
|
||||
assert!(parse("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ime_steps() {
|
||||
let s = "ime-preedit ´a\nime-preedit-end\nime-commit á\nime-key preedit apostrophe ´\nime-key pass a\nime-state\n";
|
||||
assert_eq!(
|
||||
parse(s).expect("valid script"),
|
||||
vec![
|
||||
Step::ImePreedit("´a".into()),
|
||||
Step::ImePreeditEnd,
|
||||
Step::ImeCommit("á".into()),
|
||||
Step::ImeKey("preedit".into(), "apostrophe".into(), "´".into()),
|
||||
Step::ImeKey("pass".into(), "a".into(), String::new()),
|
||||
Step::ImeState,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse("dialog-info\ndialog-remember\ndialog-click Allow\n").expect("valid"),
|
||||
vec![
|
||||
Step::DialogInfo,
|
||||
Step::DialogRemember,
|
||||
Step::DialogClick("Allow".into())
|
||||
]
|
||||
);
|
||||
assert!(parse("dialog-click").is_err());
|
||||
assert!(parse("ime-key bogus a").is_err());
|
||||
assert!(parse("ime-key commit").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mouse_steps() {
|
||||
let s = "mouse-move 1 2\nmouse-move 1.5 2 ctrl+shift\nmouse-press 1\nmouse-release 3 alt\nclick 2\ndrag 1 2 3 4\nscroll 0 -3\nresize 600 400\nclipboard-set a\\nb\nclipboard-dump\nwindows-dump\nwindow-screenshot /tmp/w.png\n";
|
||||
@@ -540,6 +958,11 @@ mod tests {
|
||||
Step::WindowScreenshot("/tmp/w.png".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse("hscroll 130\ntabs-dump").expect("valid"),
|
||||
vec![Step::HScroll(130.0), Step::TabsDump]
|
||||
);
|
||||
assert!(parse("hscroll").is_err());
|
||||
assert!(parse("mouse-move 1").is_err());
|
||||
assert!(parse("drag 1 2 3").is_err());
|
||||
assert!(parse("click x").is_err());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
#![cfg(unix)]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
//! Exec's Flatpak branch (`ghostty_os::flatpak::is_flatpak()` true) against a
|
||||
//! mock `org.freedesktop.Flatpak.Development` service on a private session
|
||||
//! bus (`dbus-run-session`): spawn through HostCommand, output via the passed
|
||||
//! pty fds, exit status propagation and SIGHUP of the process group on stop.
|
||||
//!
|
||||
//! One test only: the flatpak override is process-global.
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||
use ghostty_apprt::surface::Message as SurfaceMessage;
|
||||
use ghostty_config::{
|
||||
command::Command as ConfigCommand,
|
||||
config::Config,
|
||||
types_b::{LinuxCgroup, ShellIntegration},
|
||||
};
|
||||
use ghostty_datastruct::blocking_queue::Timeout;
|
||||
use ghostty_renderer::{
|
||||
message::Message as RendererMessage,
|
||||
size::{CellSize, Padding, ScreenSize, Size},
|
||||
};
|
||||
use ghostty_termio::{
|
||||
Mailbox,
|
||||
Options,
|
||||
RendererMailbox,
|
||||
SharedState,
|
||||
SharedStateExt,
|
||||
SurfaceMailbox,
|
||||
Termio,
|
||||
Thread,
|
||||
Wakeup,
|
||||
backend::{self, Backend},
|
||||
exec,
|
||||
message::Message,
|
||||
termio::DerivedConfig,
|
||||
};
|
||||
|
||||
#[path = "../../ghostty-os/tests/common/flatpak_mock.rs"]
|
||||
mod flatpak_mock;
|
||||
|
||||
struct SurfaceRec(Sender<SurfaceMessage>);
|
||||
|
||||
impl SurfaceMailbox for SurfaceRec {
|
||||
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
|
||||
let _ = self.0.send(msg);
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RendererRec {
|
||||
resizes: Mutex<Vec<Size>>,
|
||||
reset_blinks: AtomicUsize,
|
||||
}
|
||||
|
||||
impl RendererMailbox for RendererRec {
|
||||
fn push(&self, msg: RendererMessage, _t: Timeout) -> usize {
|
||||
match msg {
|
||||
RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s),
|
||||
RendererMessage::ResetCursorBlink => {
|
||||
self.reset_blinks.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn size(cols: u32, rows: u32) -> Size {
|
||||
Size {
|
||||
screen: ScreenSize {
|
||||
width: cols * 10,
|
||||
height: rows * 20,
|
||||
},
|
||||
cell: CellSize {
|
||||
width: 10,
|
||||
height: 20,
|
||||
},
|
||||
padding: Padding::default(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
state: SharedState,
|
||||
mailbox: Mailbox,
|
||||
stop: Wakeup,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
surface: Receiver<SurfaceMessage>,
|
||||
_renderer: Arc<RendererRec>,
|
||||
_renderer_wakeup: Wakeup,
|
||||
}
|
||||
|
||||
fn base_config() -> Config {
|
||||
Config::default()
|
||||
}
|
||||
|
||||
fn start(command: ConfigCommand, tweak: impl FnOnce(&mut Config)) -> Fixture {
|
||||
let mut config = base_config();
|
||||
tweak(&mut config);
|
||||
|
||||
let mut ecfg = exec::Config::new(ghostty_os::env::get_env_map(), "xterm-256color");
|
||||
ecfg.command = Some(command);
|
||||
ecfg.shell_integration = ShellIntegration::None;
|
||||
// The default config has `linux-cgroup = single-instance` +
|
||||
// `gtk-single-instance = detect` which only makes sense after the apprt
|
||||
// resolved it.
|
||||
ecfg.rt_pre_exec_info.linux_cgroup = LinuxCgroup::Never;
|
||||
ecfg.rt_post_fork_info.linux_cgroup = LinuxCgroup::Never;
|
||||
|
||||
let mailbox = Mailbox::new().unwrap();
|
||||
let (tx, surface) = unbounded();
|
||||
let renderer = Arc::new(RendererRec::default());
|
||||
let renderer_wakeup = Wakeup::new().unwrap();
|
||||
let termio = Termio::init(Options {
|
||||
size: size(80, 30),
|
||||
full_config: &config,
|
||||
config: DerivedConfig::new(&config),
|
||||
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
|
||||
mailbox: mailbox.clone(),
|
||||
renderer_wakeup: renderer_wakeup.clone(),
|
||||
renderer_mailbox: renderer.clone(),
|
||||
surface_mailbox: Arc::new(SurfaceRec(tx)),
|
||||
})
|
||||
.unwrap();
|
||||
let state = Arc::clone(termio.renderer_state());
|
||||
|
||||
let thread = Thread::new().unwrap();
|
||||
let stop = thread.stop_handle();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("io".into())
|
||||
.spawn(move || thread.thread_main(termio))
|
||||
.unwrap();
|
||||
|
||||
Fixture {
|
||||
state,
|
||||
mailbox,
|
||||
stop,
|
||||
thread: Some(handle),
|
||||
surface,
|
||||
_renderer: renderer,
|
||||
_renderer_wakeup: renderer_wakeup,
|
||||
}
|
||||
}
|
||||
|
||||
fn sh(script: &str) -> ConfigCommand {
|
||||
ConfigCommand::Direct(vec!["/bin/sh".into(), "-c".into(), script.into()])
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn text(&self) -> String {
|
||||
self.state.lock_terminal().plain_string().unwrap()
|
||||
}
|
||||
|
||||
fn send(&self, msg: Message) {
|
||||
self.mailbox.send(msg);
|
||||
self.mailbox.notify();
|
||||
}
|
||||
|
||||
fn write(&self, data: &[u8]) {
|
||||
self.send(Message::write_req(data));
|
||||
}
|
||||
|
||||
/// Wait until `f` returns Some or time out (panics).
|
||||
fn wait_for<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> T {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(v) = f(self) {
|
||||
return v;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for {what}; terminal text = {:?}",
|
||||
self.text()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_text(&self, needle: &str) {
|
||||
self.wait_for(&format!("text {needle:?}"), |f| {
|
||||
f.text().contains(needle).then_some(())
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for the ChildExited surface message, returning all messages
|
||||
/// received up to and including it.
|
||||
fn wait_exit(&self) -> (u32, u64, Vec<SurfaceMessage>) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
let msg = self
|
||||
.surface
|
||||
.recv_timeout(left)
|
||||
.unwrap_or_else(|_| panic!("timed out waiting for child exit; seen={seen:?}"));
|
||||
if let SurfaceMessage::ChildExited(c) = msg {
|
||||
return (c.exit_code, c.runtime_ms, seen);
|
||||
}
|
||||
seen.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.stop.notify().unwrap();
|
||||
if let Some(t) = self.thread.take() {
|
||||
t.join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop.notify();
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn flatpak_exec_spawn_exit_status_and_sighup_on_stop() {
|
||||
if !flatpak_mock::under_dbus_session("flatpak_exec_spawn_exit_status_and_sighup_on_stop") {
|
||||
return;
|
||||
}
|
||||
let info = std::env::temp_dir().join(format!("flatpak-info-termio-{}", std::process::id()));
|
||||
std::fs::write(&info, "[Application]\nname=com.mitchellh.ghostty\n").unwrap();
|
||||
assert!(ghostty_os::flatpak::set_flatpak_info_path(&info));
|
||||
assert!(ghostty_os::flatpak::is_flatpak());
|
||||
|
||||
let svc = flatpak_mock::Service::start();
|
||||
|
||||
// Spawn + output + exit status.
|
||||
let mut f = start(sh("echo from-host; exit 5"), |_| {});
|
||||
let (code, _, _) = f.wait_exit();
|
||||
assert_eq!(code, 5);
|
||||
f.wait_text("from-host");
|
||||
f.stop();
|
||||
let spawned = svc.spawned();
|
||||
assert_eq!(spawned.len(), 1, "{spawned:?}");
|
||||
assert_eq!(spawned[0].argv[0], "/bin/sh");
|
||||
assert_eq!(spawned[0].fds, vec![0, 1, 2]);
|
||||
assert!(spawned[0].env.contains_key("TERM"), "{:?}", spawned[0].env);
|
||||
|
||||
// Input reaches the host command through the pty.
|
||||
let mut f = start(sh("read x; echo got:$x"), |_| {});
|
||||
f.write(b"abc\n");
|
||||
f.wait_text("got:abc");
|
||||
let (code, _, _) = f.wait_exit();
|
||||
assert_eq!(code, 0);
|
||||
f.stop();
|
||||
|
||||
// Stopping the termio SIGHUPs the host process group.
|
||||
let mut f = start(sh("echo up; exec sleep 30"), |_| {});
|
||||
f.wait_text("up");
|
||||
let pid = svc.spawned().last().unwrap().pid;
|
||||
assert_eq!(svc.signals(), vec![]);
|
||||
f.stop();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !svc.signals().contains(&(pid, 1, true)) {
|
||||
assert!(Instant::now() < deadline, "no SIGHUP: {:?}", svc.signals());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
// ... and the host process is gone.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while std::path::Path::new(&format!("/proc/{pid}")).exists()
|
||||
&& !std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|s| s.contains(") Z"))
|
||||
{
|
||||
assert!(Instant::now() < deadline, "host command still alive");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let _ = std::fs::remove_file(&info);
|
||||
}
|
||||
Reference in new issue
Block a user