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

+49 -27
View File
@@ -1334,7 +1334,14 @@ impl Surface {
t: &mut ghostty_terminal::terminal::Terminal,
sel: Option<Selection>,
) -> Result<(), ghostty_terminal::page::Error> {
let prev = t.screen().selection;
// Compare against the previous selection BEFORE replacing it:
// `select` releases the previous selection's tracked pins, after
// which they can no longer be resolved (Zig compares through raw
// pointers that happen to stay readable).
let unchanged = match (sel, t.screen().selection) {
(Some(sel), Some(prev)) => sel.eql(&t.screen().pages, &prev),
_ => false,
};
t.screen_mut().select(sel)?;
let cfg = self.cfg();
@@ -1352,9 +1359,7 @@ impl Surface {
let Some(sel) = sel else {
return Ok(());
};
if let Some(prev) = prev
&& sel.eql(&t.screen().pages, &prev)
{
if unchanged {
return Ok(());
}
@@ -1708,28 +1713,40 @@ impl Surface {
if self.debug_shot_done.get() || self.created.elapsed().as_millis() < u128::from(delay) {
return;
}
let Some((w, h, mut px)) = self.renderer.last_frame_pixels() else {
return;
if self
.write_screenshot_png(std::path::Path::new(&path))
.is_ok()
{
self.debug_shot_done.set(true);
}
}
/// Write the last presented frame as a PNG. Requires the GL context to be
/// current.
pub fn write_screenshot_png(&self, path: &std::path::Path) -> Result<(), CoreError> {
let Some((w, h, px)) = self.renderer.last_frame_pixels() else {
return Err("no frame has been presented yet".into());
};
self.debug_shot_done.set(true);
// Bottom-up RGBA -> top-down.
let stride = w * 4;
let mut flipped = Vec::with_capacity(px.len());
for row in px.chunks_exact_mut(stride).rev() {
for row in px.chunks_exact(stride).rev() {
flipped.extend_from_slice(row);
}
let write = || -> Result<(), Box<dyn std::error::Error>> {
let file = std::io::BufWriter::new(std::fs::File::create(&path)?);
let mut enc = png::Encoder::new(file, w as u32, h as u32);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()?.write_image_data(&flipped)?;
Ok(())
};
match write() {
Ok(()) => log::info!("wrote debug screenshot to {}", path.to_string_lossy()),
Err(err) => log::warn!("debug screenshot failed: {err}"),
}
let file = std::io::BufWriter::new(std::fs::File::create(path)?);
let mut enc = png::Encoder::new(file, w as u32, h as u32);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()?.write_image_data(&flipped)?;
log::info!("wrote screenshot to {}", path.display());
Ok(())
}
/// The pixels of the last presented frame (`width`, `height`, RGBA8,
/// bottom-up). Test/debug hook for headless integration tests; requires
/// the GL context to be current on the calling thread.
pub fn frame_pixels(&self) -> Option<(usize, usize, Vec<u8>)> {
self.renderer.last_frame_pixels()
}
/// The GLArea was (re)realized; the GL context is current.
@@ -1777,15 +1794,16 @@ impl Drop for Surface {
}
}
/// The environment for the child: whatever the runtime wants plus the
/// process environment (Zig: `rt_surface.defaultTermioEnv()` falling back to
/// `getEnvMap`).
/// The environment for the child: exactly what the runtime provides
/// (`rt_surface.defaultTermioEnv()`, which already removed the variables it
/// does not want to leak); only if it provides nothing (the Zig error path)
/// do we fall back to the process environment (`getEnvMap`).
fn rt_surface_env(rt: &dyn RtSurface) -> std::collections::HashMap<String, String> {
let mut env = ghostty_os::env::get_env_map();
for (k, v) in rt.default_termio_env() {
env.insert(k, v);
let rt_env = rt.default_termio_env();
if rt_env.is_empty() {
return ghostty_os::env::get_env_map();
}
env
rt_env.into_iter().collect()
}
// ----------------------------------------------------------------------
@@ -1917,6 +1935,10 @@ impl CoreSurface for Surface {
Ok(())
}
fn write_screenshot(&self, path: &str) -> Result<(), CoreError> {
self.write_screenshot_png(std::path::Path::new(path))
}
fn id(&self) -> Option<SurfaceId> {
Some(self.id)
}