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

+66 -17
View File
@@ -31,7 +31,6 @@ use ghostty_apprt::structs::Clipboard;
use ghostty_apprt::surface::{
DesktopNotification, Message as SurfaceMessage, ReportTitleStyle, SET_TITLE_LEN, WriteReq,
};
use ghostty_config::types_a::CursorStyle as ConfigCursorStyle;
use ghostty_config::types_b::{ClipboardAccess, OSCColorReportFormat};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::size::Size;
@@ -66,16 +65,6 @@ use crate::wakeup::Wakeup;
/// `build_config.version_string`.
pub const VERSION_STRING: &str = "1.3.1";
/// The DECSCUSR style the config/default maps to.
pub fn cursor_style_from_config(style: ConfigCursorStyle) -> CursorStyle {
match style {
ConfigCursorStyle::Bar => CursorStyle::Bar,
ConfigCursorStyle::Block => CursorStyle::Block,
ConfigCursorStyle::Underline => CursorStyle::Underline,
ConfigCursorStyle::BlockHollow => CursorStyle::BlockHollow,
}
}
/// The long-lived state of the handler (everything but the terminal).
pub struct HandlerState {
/// The cached size info (`size: *renderer.Size`). Kept up to date by
@@ -120,6 +109,10 @@ pub struct HandlerState {
/// such as XTGETTCAP.
pub dcs: dcs::Handler,
/// The tmux control mode viewer state (`tmux_viewer`). Dropping it is
/// the equivalent of Zig's `viewer.deinit()` + `destroy`.
pub tmux_viewer: Option<Box<ghostty_terminal::tmux::Viewer>>,
/// This is set to true when a message was written to the termio
/// mailbox. This can be used by callers to determine if they need
/// to wake up the termio thread.
@@ -189,6 +182,7 @@ impl HandlerState {
clipboard_write: opts.clipboard_write,
apc: apc::Handler::default(),
dcs: dcs::Handler::default(),
tmux_viewer: None,
termio_messaged: false,
seen_title: false,
outbox_termio: VecDeque::new(),
@@ -332,7 +326,7 @@ impl TerminalStream {
st.osc_color_report_format = config.osc_color_report_format;
st.clipboard_write = config.clipboard_write;
st.enquiry_response = config.enquiry_response.clone().into_bytes();
st.default_cursor_style = cursor_style_from_config(config.cursor_style);
st.default_cursor_style = config.cursor_style;
st.default_cursor_blink = config.cursor_blink;
let mut h = StreamHandler { terminal, st };
@@ -653,11 +647,56 @@ impl StreamHandler<'_> {
fn dcs_command(&mut self, cmd: dcs::Command) {
match cmd {
// TODO(integration): tmux control mode viewer
// (`terminal.tmux.Viewer`) is not ported in ghostty-terminal
// yet; the events are ignored.
dcs::Command::Tmux(tmux) => {
log::info!("tmux control mode event ignored (viewer not ported): {tmux:?}");
use ghostty_terminal::tmux::control::Notification;
use ghostty_terminal::tmux::viewer::{Action as ViewerAction, Input};
log::info!("tmux control mode event cmd={tmux:?}");
match tmux {
Notification::Enter => {
// Setup our viewer state
assert!(self.st.tmux_viewer.is_none());
self.st.tmux_viewer = Some(Box::new(ghostty_terminal::tmux::Viewer::new()));
return;
}
Notification::Exit => {
// Free our viewer state if we have one
self.st.tmux_viewer = None;
return;
}
_ => {}
}
let Some(viewer) = self.st.tmux_viewer.as_mut() else {
// This can only really happen if we failed to
// initialize the viewer on enter.
log::info!("received tmux control mode command without viewer: {tmux:?}");
return;
};
let actions = viewer.next(Input::Tmux(tmux));
for action in actions {
log::info!("tmux viewer action={action:?}");
match action {
// We ignore this because we will fully exit when
// our DCS connection ends. We may want to handle
// this in the future to notify our GUI we're
// disconnected though.
ViewerAction::Exit => {}
ViewerAction::Command(command) => {
assert_eq!(command.last(), Some(&b'\n'));
self.write_req(&command);
}
// TODO (Zig has the same TODO): windows are not
// surfaced to the GUI yet.
ViewerAction::Windows(_) => {}
}
}
}
dcs::Command::Xtgettcap(mut gettcap) => {
@@ -2127,7 +2166,7 @@ mod tests {
fn change_config_updates_default_cursor_and_requests_scheme_report() {
let mut h = Harness::new();
let mut cfg = crate::termio::DerivedConfig::new(&ghostty_config::config::Config::default());
cfg.cursor_style = ConfigCursorStyle::Underline;
cfg.cursor_style = CursorStyle::Underline;
cfg.cursor_blink = Some(false);
cfg.enquiry_response = "hi".into();
h.stream.change_config(&mut h.term, &cfg);
@@ -2170,6 +2209,16 @@ mod tests {
assert_eq!("", h.term.plain_string().unwrap());
}
#[test]
fn dcs_tmux_enter_creates_viewer_and_exit_drops_it() {
let mut h = Harness::new();
h.feed(b"\x1bP1000p%begin 1 2 3\n%end 1 2 3\n");
assert!(h.stream.state.tmux_viewer.is_some());
h.feed(b"%exit\n\x1b\\after");
assert!(h.stream.state.tmux_viewer.is_none());
assert!(h.term.plain_string().unwrap().contains("after"));
}
#[test]
fn dcs_tmux_is_ignored_without_panicking() {
let mut h = Harness::new();