test(core): add headless end-to-end tests and fix bugs they found

Adds crates/ghostty-core/tests/e2e_*.rs: 81 tests that drive the real
core Surface, termio, pty and child processes through a fake runtime
(recording apprt actions, in-memory clipboard) with a headless renderer
(ghostty-renderer/src/headless.rs). They cover keys (legacy, DECCKM,
kitty, modifyOtherKeys), keybinding actions and sequences, mouse
selection/reporting/scrolling/links, clipboard incl. OSC 52 and paste
safety, resize, focus, titles/pwd/bell/notifications/progress, child
exit, and IME preedit.

Fixes for issues found by the tests and by the Codex audit
(docs/rewrite/notes/review-core-codex.md, all 9 items):
- replacing a selection with copy-on-select panicked (stale tracked
  handles); global new_window no longer inherits a parent; the child
  environment uses the runtime's env wholesale so GTK's removals stick;
  Flatpak host resources path; locale recovery (ensureLocale) in
  apprt-gtk before GTK init and for CLI actions; failed screen-file
  opens propagate errors; OSC 52 base64 rules, CSI/ESC binding length
  limits and GHOSTTY_LOG parsing match Zig.
- Also includes V2's termio cleanups (tmux viewer wiring in the stream
  handler, Pty slave semantics docs, shared cgroup wait loop).

apprt-gtk gains a debug input replay hook (GHOSTTY_DEBUG_INPUT_SCRIPT)
exercising the real GTK key path. With it, the real app was verified on
Wayland: typing and output, new tab, splits, select-all/copy/paste and
font size all work (docs/rewrite/notes/verification-V1.md). Not yet
exercised at GTK level: mouse, real IM-context IME, clipboard dialogs.

Test Plan:
- cargo test --workspace (2983 passed)
- cargo test -p ghostty-core --tests (81 e2e tests)
This commit is contained in:
ddidderr committed 2026-10-12 03:58:19 +02:00
1 parent d650e5171b
commit be6d706ba5
33 files changed
+3016 -222

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
//! Debug-only input replay (`GHOSTTY_DEBUG_INPUT_SCRIPT=<file>`).
//!
//! Compositors under test (niri) do not let us inject keys into a window, so
//! this hook synthesizes the events inside the process, at the same entry
//! points the GTK handlers use: key events go through
//! `Surface::debug_key_event` (the keyval/keycode/state translation of
//! `Surface::key_event`, i.e. `gtk/key.rs`, then `key_callback`) and text goes
//! through the IM context's `commit` signal (`im_commit`).
//!
//! Script format, one command per line (`#` starts a comment):
//!
//! ```text
//! key ctrl+shift+t # press+release; mods: ctrl shift alt super
//! text hello world # IM commit of the rest of the line
//! wait 200 # milliseconds
//! screenshot /abs/out.png # dump the renderer's last frame (PNG)
//! quit # quit the application
//! ```
//!
//! Key names are GDK keysym names (`t`, `Return`, `Tab`, `equal`, `Up`, `F5`)
//! with a few lowercase aliases (`enter`, `esc`, `up`, ...). Commands act on
//! the active surface of the active window at the time they run.
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use gtk::glib::translate::IntoGlib;
use gtk::prelude::*;
use gtk::{gdk, glib};
use crate::class::application::Application;
use crate::class::surface::Surface;
use crate::class::window::Window;
/// One parsed command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Step {
Key(String),
Text(String),
Wait(u64),
Screenshot(String),
Quit,
}
/// Parse a script. Errors carry the 1-based line number.
pub fn parse(script: &str) -> Result<Vec<Step>, String> {
let mut steps = Vec::new();
for (i, raw) in script.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (cmd, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
let rest = rest.trim_start();
let err = |m: &str| format!("line {}: {m}", i + 1);
steps.push(match cmd {
"key" if !rest.is_empty() => Step::Key(rest.to_owned()),
"text" if !rest.is_empty() => Step::Text(rest.to_owned()),
"wait" => Step::Wait(rest.parse().map_err(|_| err("wait needs milliseconds"))?),
"screenshot" if !rest.is_empty() => Step::Screenshot(rest.to_owned()),
"quit" => Step::Quit,
_ => return Err(err(&format!("bad command {line:?}"))),
});
}
Ok(steps)
}
/// Parse `ctrl+shift+t` into (keyval, state).
pub fn parse_key(spec: &str) -> Result<(gdk::Key, gdk::ModifierType), String> {
let mut state = gdk::ModifierType::empty();
let parts: Vec<&str> = spec.split('+').collect();
let (name, mods) = parts.split_last().ok_or("empty key")?;
// "ctrl++" style: the key itself is "+"
let (name, mods) = if name.is_empty() {
("plus", &mods[..mods.len().saturating_sub(1)])
} else {
(*name, mods)
};
for m in mods {
state |= match m.to_ascii_lowercase().as_str() {
"ctrl" | "control" => gdk::ModifierType::CONTROL_MASK,
"shift" => gdk::ModifierType::SHIFT_MASK,
"alt" => gdk::ModifierType::ALT_MASK,
"super" | "meta" => gdk::ModifierType::SUPER_MASK,
other => return Err(format!("unknown modifier {other:?}")),
};
}
let alias = match name.to_ascii_lowercase().as_str() {
"enter" | "return" => "Return",
"esc" | "escape" => "Escape",
"tab" => "Tab",
"space" => "space",
"backspace" => "BackSpace",
"delete" => "Delete",
"up" => "Up",
"down" => "Down",
"left" => "Left",
"right" => "Right",
"home" => "Home",
"end" => "End",
"pageup" | "page_up" => "Page_Up",
"pagedown" | "page_down" => "Page_Down",
"insert" => "Insert",
_ => name,
};
let key = gdk::Key::from_name(alias)
.or_else(|| gdk::Key::from_name(name))
.ok_or_else(|| format!("unknown key {name:?}"))?;
Ok((key, state))
}
/// Start the replay if `GHOSTTY_DEBUG_INPUT_SCRIPT` is set.
pub fn start(app: &Application) {
let Some(path) = std::env::var_os("GHOSTTY_DEBUG_INPUT_SCRIPT") else {
return;
};
let script = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(err) => {
log::error!("debug input: cannot read {}: {err}", path.to_string_lossy());
return;
}
};
let steps = match parse(&script) {
Ok(s) => s,
Err(err) => {
log::error!("debug input: {err}");
return;
}
};
log::warn!("debug input: replaying {} steps", steps.len());
let runner = Rc::new(Runner {
app: app.clone(),
steps,
next: RefCell::new(0),
});
// Wait for the first surface to be ready (realized, core initialized).
glib::timeout_add_local(Duration::from_millis(100), move || {
if runner.active_surface().is_some_and(|s| s.core().is_some()) {
runner.run();
glib::ControlFlow::Break
} else {
glib::ControlFlow::Continue
}
});
}
struct Runner {
app: Application,
steps: Vec<Step>,
next: RefCell<usize>,
}
impl Runner {
fn active_surface(&self) -> Option<Surface> {
self.app
.active_window()?
.downcast::<Window>()
.ok()?
.get_active_surface()
}
/// Run steps until the next `wait`, which reschedules us.
fn run(self: &Rc<Self>) {
loop {
let idx = *self.next.borrow();
let Some(step) = self.steps.get(idx).cloned() else {
log::warn!("debug input: script finished");
return;
};
*self.next.borrow_mut() += 1;
log::warn!("debug input: {step:?}");
match step {
Step::Wait(ms) => {
let this = Rc::clone(self);
glib::timeout_add_local_once(Duration::from_millis(ms), move || this.run());
return;
}
Step::Quit => {
self.app.quit();
return;
}
Step::Key(spec) => self.key(&spec),
Step::Text(text) => match self.active_surface() {
Some(s) => s.debug_commit_text(&text),
None => log::warn!("debug input: no active surface"),
},
Step::Screenshot(path) => match self.active_surface() {
Some(s) => s.debug_screenshot(&path),
None => log::warn!("debug input: no active surface"),
},
}
}
}
fn key(&self, spec: &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 display = surface.display();
let Some(keycode) = display
.map_keyval(key)
.and_then(|keys| keys.first().map(gdk::KeymapKey::keycode))
else {
log::error!("debug input: no keycode for {spec:?}");
return;
};
// What GTK reports as `keyval` to the key controller is the
// translated keyval; the encode path translates again from keycode.
let keyval = key.into_glib();
surface.debug_key_event(ghostty_input::key::Action::Press, keyval, keycode, state);
surface.debug_key_event(ghostty_input::key::Action::Release, keyval, keycode, state);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_script() {
let s =
"# c\n\nkey ctrl+shift+t\ntext hello world\nwait 200\nscreenshot /tmp/a.png\nquit\n";
assert_eq!(
parse(s).expect("valid script"),
vec![
Step::Key("ctrl+shift+t".into()),
Step::Text("hello world".into()),
Step::Wait(200),
Step::Screenshot("/tmp/a.png".into()),
Step::Quit
]
);
assert!(parse("wait x").is_err());
assert!(parse("bogus").is_err());
}
}