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

+6 -26
View File
@@ -570,30 +570,6 @@ impl Surface {
Ok(Text { text, viewport })
}
/// Open a URL through the runtime, falling back to the default opener
/// (Zig: `openUrl`).
pub(crate) fn open_url_with_fallback(&self, action: OpenUrl) {
// If the apprt handles it then we're done.
let kind = action.kind;
let url = action.url.clone();
if self.perform(RtAction::OpenUrl(action)) {
return;
}
// apprt didn't handle it, fallback to our simple cross-platform URL
// opener. We log a warning because we want well-behaved apprts to
// handle this themselves.
log::warn!("apprt did not handle open URL action, falling back to default opener");
let kind = match kind {
OpenUrlKind::Unknown => ghostty_os::open::OpenUrlKind::Unknown,
OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
};
if let Err(err) = ghostty_os::open::open(kind, &url) {
log::warn!("failed to open url err={err}");
}
}
/// Write the screen/history/selection to a temp file and paste/copy/open
/// the path (Zig: `writeScreenFile`).
fn write_screen_file(
@@ -620,13 +596,13 @@ impl Surface {
false,
);
}
WriteScreenAction::Open => self.open_url_with_fallback(OpenUrl {
WriteScreenAction::Open => self.open_url(OpenUrl {
kind: match write_screen.emit {
WriteScreenFormat::Plain | WriteScreenFormat::Vt => OpenUrlKind::Text,
WriteScreenFormat::Html => OpenUrlKind::Html,
},
url: path,
}),
})?,
WriteScreenAction::Paste => self.queue_io(IoMessage::write_req(path.as_bytes())),
}
Ok(())
@@ -700,6 +676,10 @@ impl Surface {
} else {
format!("\x1b{data}")
};
// Zig formats into a fixed 128 byte buffer (`NoSpaceLeft`).
if full.len() > 128 {
return Err("csi/esc binding sequence too long (max 128 bytes)".into());
}
self.queue_io(IoMessage::write_req(full.as_bytes()));
// CSI/ESC triggers a scroll.