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
d650e5171b
commit
be6d706ba5
33 files changed
+3016
-222
No files matched your search
@@ -0,0 +1,255 @@
|
||||
//! Keyboard input end to end: key events -> core Surface -> encoder -> pty ->
|
||||
//! a real `cat` child that records the exact bytes it received.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use ghostty_input::Key;
|
||||
|
||||
fn rec_args(setup: &str, args: &[&str]) -> Option<Harness> {
|
||||
Harness::recorder(setup, args)
|
||||
}
|
||||
|
||||
fn rec(setup: &str) -> Option<Harness> {
|
||||
Harness::recorder(setup, &[])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printable_text_enter_backspace_tab() {
|
||||
let h = harness!(rec(""));
|
||||
h.type_text("Hello, World! 123");
|
||||
h.key(Key::Enter, none(), "");
|
||||
h.key(Key::Backspace, none(), "");
|
||||
h.key(Key::Tab, none(), "");
|
||||
h.key(Key::Tab, shift(), "");
|
||||
h.key(Key::Escape, none(), "");
|
||||
h.key(Key::Space, none(), " ");
|
||||
h.expect_input(b"Hello, World! 123\r\x7f\t\x1b[Z\x1b ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_text_is_passed_through() {
|
||||
let h = harness!(rec(""));
|
||||
h.key(Key::KeyE, none(), "\u{e9}");
|
||||
h.key(Key::Unidentified, none(), "\u{1f600}");
|
||||
h.expect_input("\u{e9}\u{1f600}".as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_keys() {
|
||||
let h = harness!(rec(""));
|
||||
h.key(Key::KeyC, ctrl(), "c");
|
||||
h.key(Key::KeyD, ctrl(), "d");
|
||||
h.key(Key::KeyA, ctrl(), "a");
|
||||
h.key(Key::KeyZ, ctrl(), "z");
|
||||
// ctrl+[ is deliberately not in Ghostty's ctrl table (kitty behaviour): CSI u.
|
||||
h.key(Key::BracketLeft, ctrl(), "[");
|
||||
h.key(Key::Space, ctrl(), " ");
|
||||
h.expect_input(b"\x03\x04\x01\x1a\x1b[91;5u\x00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_prefix() {
|
||||
let h = harness!(rec(""));
|
||||
h.key(Key::KeyX, alt(), "x");
|
||||
h.key(Key::KeyB, alt(), "b");
|
||||
h.key(Key::Enter, alt(), "");
|
||||
h.key(Key::Backspace, alt(), "");
|
||||
h.expect_input(b"\x1bx\x1bb\x1b\r\x1b\x7f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt() {
|
||||
let h = harness!(rec(""));
|
||||
h.key(
|
||||
Key::KeyC,
|
||||
ghostty_input::Mods {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
"c",
|
||||
);
|
||||
h.expect_input(b"\x1b\x03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_normal_mode() {
|
||||
let h = harness!(rec(""));
|
||||
h.special(Key::ArrowUp, none());
|
||||
h.special(Key::ArrowDown, none());
|
||||
h.special(Key::ArrowRight, none());
|
||||
h.special(Key::ArrowLeft, none());
|
||||
h.special(Key::Home, none());
|
||||
h.special(Key::End, none());
|
||||
h.special(Key::PageUp, none());
|
||||
h.special(Key::PageDown, none());
|
||||
h.special(Key::Insert, none());
|
||||
h.special(Key::Delete, none());
|
||||
h.expect_input(b"\x1b[A\x1b[B\x1b[C\x1b[D\x1b[H\x1b[F\x1b[5~\x1b[6~\x1b[2~\x1b[3~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_decckm_application_mode() {
|
||||
let h = harness!(rec(r"\033[?1h"));
|
||||
h.special(Key::ArrowUp, none());
|
||||
h.special(Key::ArrowDown, none());
|
||||
h.special(Key::ArrowRight, none());
|
||||
h.special(Key::ArrowLeft, none());
|
||||
h.special(Key::Home, none());
|
||||
h.special(Key::End, none());
|
||||
// Modified arrows always use CSI form.
|
||||
h.special(Key::ArrowUp, shift());
|
||||
h.special(Key::ArrowLeft, ctrl());
|
||||
h.expect_input(b"\x1bOA\x1bOB\x1bOC\x1bOD\x1bOH\x1bOF\x1b[1;2A\x1b[1;5D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_keys() {
|
||||
let h = harness!(rec(""));
|
||||
for k in [
|
||||
Key::F1,
|
||||
Key::F2,
|
||||
Key::F3,
|
||||
Key::F4,
|
||||
Key::F5,
|
||||
Key::F6,
|
||||
Key::F7,
|
||||
Key::F8,
|
||||
Key::F9,
|
||||
Key::F10,
|
||||
Key::F11,
|
||||
Key::F12,
|
||||
] {
|
||||
h.special(k, none());
|
||||
}
|
||||
h.special(Key::F1, shift());
|
||||
h.special(Key::F5, ctrl());
|
||||
h.expect_input(
|
||||
b"\x1bOP\x1bOQ\x1bOR\x1bOS\x1b[15~\x1b[17~\x1b[18~\x1b[19~\x1b[20~\x1b[21~\x1b[23~\x1b[24~\x1b[1;2P\x1b[15;5~",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keypad_normal_and_application() {
|
||||
// numlock on, normal keypad mode: plain digits
|
||||
let h = harness!(rec(""));
|
||||
h.key(Key::Numpad1, none(), "1");
|
||||
h.key(Key::NumpadAdd, none(), "+");
|
||||
h.key(Key::NumpadEnter, none(), "");
|
||||
h.expect_input(b"1+\r");
|
||||
|
||||
// DECKPAM alone is ignored (mode 1035 ignore_keypad_with_numlock defaults on).
|
||||
let h = harness!(rec(r"\033="));
|
||||
h.key(Key::Numpad1, none(), "1");
|
||||
h.expect_input(b"1");
|
||||
|
||||
// DECKPAM + mode 1035 off: application keypad sequences
|
||||
let h = harness!(rec(r"\033=\033[?1035l"));
|
||||
h.key(Key::Numpad1, none(), "1");
|
||||
h.key(Key::NumpadAdd, none(), "+");
|
||||
h.key(Key::NumpadEnter, none(), "");
|
||||
h.expect_input(b"\x1bOq\x1bOk\x1bOM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_keyboard_disambiguate() {
|
||||
let h = harness!(rec(r"\033[>1u"));
|
||||
h.wait("kitty flags", |h| {
|
||||
h.surface
|
||||
.lock_terminal()
|
||||
.screen()
|
||||
.kitty_keyboard
|
||||
.current()
|
||||
.int()
|
||||
== 1
|
||||
});
|
||||
h.key(Key::KeyC, ctrl(), "c");
|
||||
h.key(Key::Escape, none(), "");
|
||||
h.key(Key::KeyX, alt(), "x");
|
||||
h.key(Key::KeyA, none(), "a");
|
||||
h.key(Key::Enter, none(), "");
|
||||
h.key(Key::Enter, shift(), "");
|
||||
h.special(Key::ArrowUp, none());
|
||||
h.special(Key::F1, none());
|
||||
h.expect_input(b"\x1b[99;5u\x1b[27u\x1b[120;3ua\r\x1b[13;2u\x1b[A\x1b[P");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_keyboard_report_events_sends_release() {
|
||||
// flags 1|2: disambiguate + report event types
|
||||
let h = harness!(rec(r"\033[>3u"));
|
||||
h.wait("kitty flags", |h| {
|
||||
h.surface
|
||||
.lock_terminal()
|
||||
.screen()
|
||||
.kitty_keyboard
|
||||
.current()
|
||||
.int()
|
||||
== 3
|
||||
});
|
||||
h.key(Key::KeyA, none(), "a");
|
||||
h.key(Key::KeyC, ctrl(), "c");
|
||||
// press "a" is plain text, release "a" is reported
|
||||
h.expect_input(b"a\x1b[97;1:3u\x1b[99;5u\x1b[99;5:3u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_keyboard_pop_restores_legacy() {
|
||||
let h = harness!(rec(r"\033[>1u\033[<1u"));
|
||||
h.wait("kitty popped", |h| {
|
||||
h.surface
|
||||
.lock_terminal()
|
||||
.screen()
|
||||
.kitty_keyboard
|
||||
.current()
|
||||
.int()
|
||||
== 0
|
||||
});
|
||||
h.key(Key::KeyC, ctrl(), "c");
|
||||
h.expect_input(b"\x03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_other_keys_state_2() {
|
||||
let h = harness!(rec(r"\033[>4;2m"));
|
||||
h.wait("mok2", |h| {
|
||||
h.surface.lock_terminal().flags.modify_other_keys_2
|
||||
});
|
||||
// Plain ctrl+letter stays legacy, other ctrl combos use CSI 27.
|
||||
h.key(Key::KeyA, ctrl(), "a");
|
||||
h.key(Key::Semicolon, ctrl(), ";");
|
||||
h.key(Key::Digit1, ctrl(), "1");
|
||||
h.key(Key::KeyA, none(), "a");
|
||||
h.expect_input(b"\x01\x1b[27;5;59~1a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_input_while_disable_keyboard_mode() {
|
||||
// KAM (CSI 2 h) disables keyboard input.
|
||||
let h = harness!(rec_args(r"\033[2h", &["--vt-kam-allowed=true"]));
|
||||
h.wait("kam", |h| {
|
||||
h.surface
|
||||
.lock_terminal()
|
||||
.modes
|
||||
.get(ghostty_vt::modes::Mode::DisableKeyboard)
|
||||
});
|
||||
h.type_text("zz");
|
||||
h.settle(100);
|
||||
assert_eq!(h.recorded(), Vec::<u8>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_release_without_press_is_ignored_in_legacy() {
|
||||
let h = harness!(rec(""));
|
||||
// Only a release event: nothing should be sent.
|
||||
h.key_event(ghostty_input::KeyEvent {
|
||||
action: ghostty_input::key::Action::Release,
|
||||
key: Key::KeyA,
|
||||
utf8: "a",
|
||||
..Default::default()
|
||||
});
|
||||
h.type_text("b");
|
||||
h.expect_input(b"b");
|
||||
}
|
||||
Reference in new issue
Block a user