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
@@ -26,21 +26,29 @@ impl Default for Logging {
|
||||
}
|
||||
|
||||
impl Logging {
|
||||
/// Parse the `GHOSTTY_LOG` value (comma separated field names, an
|
||||
/// optional `no-` prefix disables; Zig: `cli.args.parsePackedStruct`).
|
||||
/// Invalid values yield the default.
|
||||
/// Parse the `GHOSTTY_LOG` value (Zig: `cli.args.parsePackedStruct`):
|
||||
/// a standalone boolean (`1/t/T/true/0/f/F/false`) sets every field,
|
||||
/// otherwise a comma separated list of field names where a `no-` prefix
|
||||
/// disables. Fields not mentioned keep their default (stderr on). Any
|
||||
/// invalid value (including an empty part) yields the default.
|
||||
#[must_use]
|
||||
pub fn parse(v: &str) -> Logging {
|
||||
let mut out = Logging { stderr: false };
|
||||
match v {
|
||||
"1" | "t" | "T" | "true" => return Logging { stderr: true },
|
||||
"0" | "f" | "F" | "false" => return Logging { stderr: false },
|
||||
_ => {}
|
||||
}
|
||||
let mut out = Logging::default();
|
||||
for part in v.split(',') {
|
||||
let part = part.trim();
|
||||
let part = part.trim_matches([' ', '\t']);
|
||||
let (name, value) = match part.strip_prefix("no-") {
|
||||
Some(n) => (n, false),
|
||||
None => (part, true),
|
||||
};
|
||||
match name {
|
||||
"stderr" => out.stderr = value,
|
||||
"macos" | "" => {}
|
||||
// The only other field (macOS unified logging) is a no-op.
|
||||
"macos" => {}
|
||||
_ => return Logging::default(),
|
||||
}
|
||||
}
|
||||
@@ -87,11 +95,16 @@ pub struct GlobalState {
|
||||
static STATE: OnceLock<GlobalState> = OnceLock::new();
|
||||
|
||||
/// Options for [`init`].
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct InitOptions {
|
||||
/// A CLI action is being executed: disable stderr logging by default
|
||||
/// since the action writes to stderr/stdout itself.
|
||||
pub action: bool,
|
||||
|
||||
/// The runtime-specific resources directory discovery (Zig:
|
||||
/// `apprt.runtime.resourcesDir`, e.g. GTK's Flatpak host path lookup).
|
||||
/// `None` uses the generic `ghostty_os::resourcesdir::resources_dir`.
|
||||
pub resources_dir: Option<fn() -> std::io::Result<ResourcesDir>>,
|
||||
}
|
||||
|
||||
/// The global state. If [`init`] was never called, a default state (no
|
||||
@@ -132,7 +145,10 @@ pub fn init(opts: InitOptions) -> &'static GlobalState {
|
||||
|
||||
// Find our resources directory once for the app so every launch
|
||||
// hereafter can use this cached value.
|
||||
let resources_dir = match ghostty_os::resourcesdir::resources_dir() {
|
||||
let discover = opts
|
||||
.resources_dir
|
||||
.unwrap_or(ghostty_os::resourcesdir::resources_dir);
|
||||
let resources_dir = match discover() {
|
||||
Ok(d) => d,
|
||||
Err(err) => {
|
||||
log::warn!("failed to find resources dir err={err}");
|
||||
@@ -224,7 +240,14 @@ mod tests {
|
||||
fn logging_parse() {
|
||||
assert!(Logging::parse("stderr").stderr);
|
||||
assert!(!Logging::parse("no-stderr").stderr);
|
||||
assert!(!Logging::parse("macos").stderr);
|
||||
// Unmentioned fields keep their default (stderr on).
|
||||
assert!(Logging::parse("macos").stderr);
|
||||
assert!(!Logging::parse("false").stderr);
|
||||
assert!(!Logging::parse("0").stderr);
|
||||
assert!(Logging::parse("true").stderr);
|
||||
// Empty / invalid values yield the default.
|
||||
assert!(Logging::parse("").stderr);
|
||||
assert!(Logging::parse("stderr,").stderr);
|
||||
assert_eq!(Logging::parse("garbage"), Logging::default());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -43,13 +43,19 @@ impl Surface {
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode. Zig tolerates a missing-padding error ("It is possible to
|
||||
// actually have valid data and get this error, so we allow it"), so
|
||||
// decode leniently without requiring padding.
|
||||
// Zig validates the size first (`calcSizeForSlice` on the padded
|
||||
// standard alphabet rejects lengths that aren't a multiple of 4),
|
||||
// then decodes ignoring only the `InvalidPadding` error (e.g. nonzero
|
||||
// trailing bits as in "YR==", which still decodes to "a").
|
||||
if !data.len().is_multiple_of(4) {
|
||||
log::info!("application sent invalid base64 data for OSC 52");
|
||||
return;
|
||||
}
|
||||
let lenient = base64::engine::GeneralPurpose::new(
|
||||
&base64::alphabet::STANDARD,
|
||||
base64::engine::GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
|
||||
.with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireCanonical)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
let Ok(decoded) = lenient.decode(data) else {
|
||||
log::info!("application sent invalid base64 data for OSC 52");
|
||||
@@ -78,23 +84,8 @@ impl Surface {
|
||||
|
||||
// The options we'll use for all formatting. We'll just override the
|
||||
// emit format.
|
||||
let codepoint_map: Vec<formatter::CodepointMap> = cfg
|
||||
.clipboard_codepoint_map
|
||||
.map
|
||||
.list
|
||||
.iter()
|
||||
.map(|e| formatter::CodepointMap {
|
||||
range: e.range,
|
||||
replacement: match &e.replacement {
|
||||
ghostty_config::clipboard_codepoint_map::Replacement::Codepoint(c) => {
|
||||
formatter::Replacement::Codepoint(*c)
|
||||
}
|
||||
ghostty_config::clipboard_codepoint_map::Replacement::String(s) => {
|
||||
formatter::Replacement::String(s.clone())
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let codepoint_map: Vec<formatter::CodepointMap> =
|
||||
cfg.clipboard_codepoint_map.map.list.clone();
|
||||
let opts = formatter::Options {
|
||||
emit: Format::Plain, // We'll override this below
|
||||
unwrap: true,
|
||||
|
||||
@@ -731,12 +731,9 @@ impl Surface {
|
||||
BindingAction::Redo => RtAction::Redo,
|
||||
_ => return,
|
||||
};
|
||||
let target = if matches!(rt, RtAction::NewWindow) {
|
||||
Target::Surface(self.id)
|
||||
} else {
|
||||
Target::App
|
||||
};
|
||||
self.rt_app.perform_action(target, rt);
|
||||
// Zig `App.performAction`: new_window is created with parent = null,
|
||||
// so it never inherits from the invoking surface.
|
||||
self.rt_app.perform_action(Target::App, rt);
|
||||
}
|
||||
|
||||
/// Deactivate every key table. Returns whether any was active.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1663,7 +1663,7 @@ impl Surface {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn open_url(&self, action: OpenUrl) -> Result<(), CoreError> {
|
||||
pub(crate) fn open_url(&self, action: OpenUrl) -> Result<(), CoreError> {
|
||||
// If the apprt handles it then we're done.
|
||||
let kind = action.kind;
|
||||
let url = action.url.clone();
|
||||
|
||||
Reference in new issue
Block a user