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:
1 parent
7cdc0e9055
commit
2f58f0214d
33 files changed
+3016
-222
No files matched your search
@@ -17,7 +17,6 @@ use std::{
|
||||
ffi::{CString, NulError, c_char},
|
||||
os::fd::OwnedFd,
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ghostty_os::file::Rlimit;
|
||||
@@ -497,23 +496,7 @@ pub fn fmt_scope(pid: u32) -> String {
|
||||
format!("app-ghostty-surface-transient-{pid}.scope")
|
||||
}
|
||||
|
||||
/// Wait for the current process' cgroup to end in `expected`. Returns true if
|
||||
/// seen, false on timeout (250ms).
|
||||
fn wait_for_cgroup(pid: u32, expected: &str) -> bool {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > Duration::from_millis(250) {
|
||||
return false;
|
||||
}
|
||||
if let Some(raw) = ghostty_os::cgroup::current(pid)
|
||||
&& let Some(idx) = raw.rfind('/')
|
||||
&& &raw[idx + 1..] == expected
|
||||
{
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
use ghostty_os::cgroup::wait_for_scope as wait_for_cgroup;
|
||||
|
||||
/// If we are expecting to be moved to a transient systemd scope, wait to see
|
||||
/// if that happens by checking for the correct name of the current cgroup.
|
||||
|
||||
@@ -771,10 +771,20 @@ impl Subprocess {
|
||||
|
||||
// Assume that the resources directory is adjacent to the terminfo
|
||||
// database
|
||||
let parent = std::path::Path::new(base)
|
||||
.parent()
|
||||
.map_or_else(String::new, |p| p.to_string_lossy().into_owned());
|
||||
env.insert("TERMINFO".into(), format!("{parent}/terminfo"));
|
||||
//
|
||||
// Rust-specific: the in-repo dev resources dir (`<ws>/resources`)
|
||||
// carries its compiled terminfo inside itself, so prefer that
|
||||
// when present.
|
||||
let own = format!("{base}/terminfo");
|
||||
let terminfo = if std::path::Path::new(&format!("{own}/x/xterm-ghostty")).exists() {
|
||||
own
|
||||
} else {
|
||||
let parent = std::path::Path::new(base)
|
||||
.parent()
|
||||
.map_or_else(String::new, |p| p.to_string_lossy().into_owned());
|
||||
format!("{parent}/terminfo")
|
||||
};
|
||||
env.insert("TERMINFO".into(), terminfo);
|
||||
} else {
|
||||
env.insert("TERM".into(), "xterm-256color".into());
|
||||
env.insert("COLORTERM".into(), "truecolor".into());
|
||||
|
||||
@@ -92,8 +92,13 @@ pub enum Error {
|
||||
/// A pty master/slave pair.
|
||||
///
|
||||
/// Deviation from Zig: both fds are `OwnedFd` and are closed on drop (the Zig
|
||||
/// struct never closes the slave automatically). To close the slave early
|
||||
/// (as Exec does after the fork) use [`Pty::close_slave`] or destructure.
|
||||
/// struct never closes the slave automatically). This is behaviour-preserving:
|
||||
/// Zig's `Exec.Subprocess.start` closes the slave explicitly in exactly two
|
||||
/// places (in the parent right after the child was started successfully, and
|
||||
/// in the start error path before `pty.deinit()`), and `Pty.deinit` is only
|
||||
/// reached afterwards. The Rust `Exec` calls [`Pty::close_slave`] after a
|
||||
/// successful start and drops the whole `Pty` on error, so the slave fd lives
|
||||
/// exactly as long as in Zig.
|
||||
#[derive(Debug)]
|
||||
pub struct Pty {
|
||||
pub master: OwnedFd,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,7 +29,7 @@ use std::time::{Duration, Instant};
|
||||
use ghostty_config::conditional;
|
||||
use ghostty_config::config::Config;
|
||||
use ghostty_config::io::ReadableIO;
|
||||
use ghostty_config::types_a::{Color, CursorStyle as ConfigCursorStyle, TerminalColor};
|
||||
use ghostty_config::types_a::{Color, CursorStyle, TerminalColor};
|
||||
use ghostty_config::types_b::{ClipboardAccess, GraphemeWidthMethod, OSCColorReportFormat};
|
||||
use ghostty_datastruct::blocking_queue::Timeout;
|
||||
use ghostty_renderer::message::Message as RendererMessage;
|
||||
@@ -52,9 +52,7 @@ use crate::message::{Message, SizeReport};
|
||||
use crate::options::Options;
|
||||
use crate::sinks::{RendererMailbox, SurfaceMailbox};
|
||||
use crate::state::{SharedState, SharedStateExt, State};
|
||||
use crate::stream_handler::{
|
||||
HandlerOptions, HandlerState, TerminalStream, cursor_style_from_config,
|
||||
};
|
||||
use crate::stream_handler::{HandlerOptions, HandlerState, TerminalStream};
|
||||
use crate::wakeup::Wakeup;
|
||||
|
||||
/// Errors from termio initialization and thread setup.
|
||||
@@ -95,7 +93,7 @@ pub enum MutexState {
|
||||
pub struct DerivedConfig {
|
||||
pub palette: Palette,
|
||||
pub image_storage_limit: usize,
|
||||
pub cursor_style: ConfigCursorStyle,
|
||||
pub cursor_style: CursorStyle,
|
||||
pub cursor_blink: Option<bool>,
|
||||
pub cursor_color: Option<TerminalColor>,
|
||||
pub foreground: Color,
|
||||
@@ -376,7 +374,7 @@ impl Termio {
|
||||
set_image_limits(&mut term, config.image_storage_limit);
|
||||
|
||||
// Set our default cursor style
|
||||
term.screen_mut().cursor.cursor_style = cursor_style_from_config(config.cursor_style);
|
||||
term.screen_mut().cursor.cursor_style = config.cursor_style;
|
||||
|
||||
// Setup our terminal size in pixels for certain requests.
|
||||
term.width_px = u32::from(term.cols) * size.cell.width;
|
||||
@@ -391,7 +389,7 @@ impl Termio {
|
||||
termio_mailbox: mailbox.clone(),
|
||||
surface_mailbox: Arc::clone(&surface_mailbox),
|
||||
renderer_wakeup: renderer_wakeup.clone(),
|
||||
default_cursor_style: cursor_style_from_config(config.cursor_style),
|
||||
default_cursor_style: config.cursor_style,
|
||||
default_cursor_blink: config.cursor_blink,
|
||||
enquiry_response: config.enquiry_response.clone().into_bytes(),
|
||||
osc_color_report_format: config.osc_color_report_format,
|
||||
|
||||
Reference in new issue
Block a user