test(windows): run the Windows code under Wine; theme preview parity

Windows was previously only compile-checked. With Wine 11 (staging)
and mingw available, scripts/windows-wine-test.sh (`just
test-windows-wine`) builds expat/FreeType/fontconfig for mingw under
target/wine-deps and runs the test suites of ghostty-vt, unicode,
datastruct, input, os, config, terminal, crash, termio and cli under
Wine (~2,700 tests). New termio/tests/exec_windows.rs drives real
cmd.exe children over ConPTY through Termio (output, exit codes,
typing, 120 KiB writes, stop, start failure). Fixes found: Windows
paths from fs::canonicalize leaked the \\?\ prefix (new
ghostty_os::path::realpath strips it like Zig); ghostty-font compiles
hb-ft for bundled HarfBuzz on Windows; ~25 tests had Unix assumptions.
Wine limitations are ignored with reasons (ResizePseudoConsole stub,
no POSIX rename over existing files) - docs/rewrite/notes/windows-wine.md.

+list-themes preview parity (former KNOWN_DIFFERENCES rows 5-9): the
search box ports vaxis TextInput (word navigation and editing bindings,
grapheme-wise editing), ASCII-only case folding, light initial colors
until terminal reports arrive (no COLORFGBG), and load errors end the
preview with exit 1; PTY tests compare with /usr/bin/ghostty.

Test Plan:
- just test-windows-wine
- cargo test -p ghostty-cli --test list_themes_pty
- cargo test --workspace --no-fail-fast (3601 passed)
This commit is contained in:
ddidderr committed 2026-10-12 17:23:24 +02:00
1 parent d1eb32b4f7
commit 1431a8a1d8
30 files changed
+1668 -240

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ windows-sys = { version = "0.61", features = [
# and only allowed on minimal, SAFETY-commented items.
[lints.rust]
unsafe_code = "deny"
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(wine)"] }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
+17 -7
View File
@@ -452,7 +452,9 @@ mod tests {
fn new(shell: Shell) -> Self {
let tmp = TempDir::init().unwrap();
let path = std::fs::canonicalize(tmp.path())
// (`canonicalize` would give a `\\?\` verbatim path on Windows,
// which rejects the `/` separators used below.)
let path = ghostty_os::path::realpath(tmp.path())
.unwrap()
.to_string_lossy()
.into_owned();
@@ -814,7 +816,7 @@ mod tests {
#[test]
fn bash_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(
setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env)
@@ -838,7 +840,11 @@ mod tests {
.unwrap()
);
assert_eq!(
format!("{}/shell-integration:/usr/local/share:/usr/share", res.path),
format!(
"{}/shell-integration{}/usr/local/share:/usr/share",
res.path,
if cfg!(windows) { ';' } else { ':' }
),
env.get("XDG_DATA_DIRS").unwrap().to_str().unwrap()
);
}
@@ -858,7 +864,11 @@ mod tests {
.unwrap()
);
assert_eq!(
format!("{}/shell-integration:/opt/share", res.path),
format!(
"{}/shell-integration{}/opt/share",
res.path,
if cfg!(windows) { ';' } else { ':' }
),
env.get("XDG_DATA_DIRS").unwrap().to_str().unwrap()
);
}
@@ -866,7 +876,7 @@ mod tests {
#[test]
fn xdg_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env));
assert_eq!(0, env.len());
@@ -914,7 +924,7 @@ mod tests {
#[test]
fn nushell_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len());
@@ -955,7 +965,7 @@ mod tests {
#[test]
fn zsh_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let dir = ghostty_os::path::realpath(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none());
assert_eq!(0, env.len());
+423
View File
@@ -0,0 +1,423 @@
#![cfg(windows)]
#![allow(clippy::unwrap_used, unsafe_code)]
//! End-to-end tests of the Windows backend: real `cmd.exe` children on a real
//! ConPTY, driven through `Termio` + `Thread` (IO thread: `WaitForMultipleObjects`
//! loop) + the pipe read thread, against a real `Terminal`. Counterpart of
//! `exec_integration` (Unix). Runs under Wine via `scripts/windows-wine-test.sh`.
use std::{
sync::{
Arc,
Mutex,
atomic::{AtomicUsize, Ordering},
},
thread::JoinHandle,
time::{Duration, Instant},
};
use crossbeam_channel::{Receiver, Sender, unbounded};
use ghostty_apprt::surface::Message as SurfaceMessage;
use ghostty_config::{
command::Command as ConfigCommand,
config::Config,
types_b::ShellIntegration,
};
use ghostty_datastruct::blocking_queue::Timeout;
use ghostty_renderer::{
message::Message as RendererMessage,
size::{CellSize, Padding, ScreenSize, Size},
};
use ghostty_termio::{
Mailbox,
Options,
RendererMailbox,
SharedState,
SharedStateExt,
SurfaceMailbox,
Termio,
Thread,
Wakeup,
backend::{self, Backend},
exec,
message::Message,
termio::DerivedConfig,
};
struct SurfaceRec(Sender<SurfaceMessage>);
impl SurfaceMailbox for SurfaceRec {
fn push(&self, msg: SurfaceMessage, _t: Timeout) -> usize {
let _ = self.0.send(msg);
1
}
}
#[derive(Default)]
struct RendererRec {
resizes: Mutex<Vec<Size>>,
wakeups: AtomicUsize,
}
impl RendererMailbox for RendererRec {
fn push(&self, msg: RendererMessage, _t: Timeout) -> usize {
match msg {
RendererMessage::Resize(s) => self.resizes.lock().unwrap().push(s),
RendererMessage::ResetCursorBlink => {
self.wakeups.fetch_add(1, Ordering::SeqCst);
}
_ => {}
}
1
}
}
fn size(cols: u32, rows: u32) -> Size {
Size {
screen: ScreenSize {
width: cols * 10,
height: rows * 20,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding::default(),
}
}
struct Fixture {
state: SharedState,
mailbox: Mailbox,
stop: Wakeup,
thread: Option<JoinHandle<()>>,
surface: Receiver<SurfaceMessage>,
renderer: Arc<RendererRec>,
}
fn start(command: ConfigCommand) -> Fixture {
start_with(command, |_| {})
}
fn start_with(command: ConfigCommand, tweak: impl FnOnce(&mut exec::Config)) -> Fixture {
let config = Config::default();
let mut ecfg = exec::Config::new(
ghostty_os::env::get_env_map(),
ghostty_os::osbytes::EncodedOs::from_plain_text("xterm-256color"),
);
ecfg.command = Some(command);
ecfg.shell_integration = ShellIntegration::None;
// A short cwd keeps the cmd.exe prompt from wrapping at 80 columns.
ecfg.working_directory = Some(ghostty_os::osbytes::EncodedOs::from_plain_text(r"C:\"));
tweak(&mut ecfg);
let mailbox = Mailbox::new().unwrap();
let (tx, surface) = unbounded();
let renderer = Arc::new(RendererRec::default());
let termio = Termio::init(Options {
size: size(80, 30),
full_config: &config,
config: DerivedConfig::new(&config),
backend: Backend::new(backend::Config::Exec(ecfg)).unwrap(),
mailbox: mailbox.clone(),
renderer_wakeup: Wakeup::new().unwrap(),
renderer_mailbox: renderer.clone(),
surface_mailbox: Arc::new(SurfaceRec(tx)),
})
.unwrap();
let state = Arc::clone(termio.renderer_state());
let thread = Thread::new().unwrap();
let stop = thread.stop_handle();
let handle = std::thread::Builder::new()
.name("io".into())
.spawn(move || thread.thread_main(termio))
.unwrap();
Fixture {
state,
mailbox,
stop,
thread: Some(handle),
surface,
renderer,
}
}
fn cmd(args: &[&str]) -> ConfigCommand {
let mut v = vec!["cmd.exe".into()];
v.extend(args.iter().map(|s| (*s).into()));
ConfigCommand::Direct(v)
}
impl Fixture {
fn text(&self) -> String {
self.state.lock_terminal().plain_string().unwrap()
}
fn send(&self, msg: Message) {
self.mailbox.send(msg);
self.mailbox.notify();
}
fn write(&self, data: &[u8]) {
self.send(Message::write_req(data));
}
fn wait_for<T>(&self, what: &str, mut f: impl FnMut(&Fixture) -> Option<T>) -> T {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(v) = f(self) {
return v;
}
assert!(
Instant::now() < deadline,
"timed out waiting for {what}; terminal text = {:?}",
self.text()
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_text(&self, needle: &str) {
self.wait_for(&format!("text {needle:?}"), |f| {
f.text().contains(needle).then_some(())
});
}
/// Wait for the ChildExited surface message: (exit code, runtime ms).
fn wait_exit(&self) -> (u32, u64) {
let deadline = Instant::now() + Duration::from_secs(30);
let mut seen = Vec::new();
loop {
let left = deadline.saturating_duration_since(Instant::now());
let msg = self.surface.recv_timeout(left).unwrap_or_else(|_| {
panic!(
"timed out waiting for child exit; seen={seen:?}; text={:?}",
self.text()
)
});
if let SurfaceMessage::ChildExited(c) = msg {
return (c.exit_code, c.runtime_ms);
}
seen.push(msg);
}
}
fn stop(&mut self) {
self.stop.notify().unwrap();
if let Some(t) = self.thread.take() {
t.join().unwrap();
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = self.stop.notify();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
#[test]
fn echo_output_reaches_terminal_and_exit_is_reported() {
let mut f = start(cmd(&["/c", "echo hi"]));
let (code, runtime_ms) = f.wait_exit();
assert_eq!(code, 0);
assert!(runtime_ms < 30_000);
// The reader thread may still be draining the pipe after the exit event.
f.wait_text("hi");
assert!(f.renderer.wakeups.load(Ordering::SeqCst) >= 1);
f.stop();
}
#[test]
fn nonzero_exit_code_is_reported() {
let mut f = start(cmd(&["/c", "exit 7"]));
let (code, _) = f.wait_exit();
assert_eq!(code, 7);
f.stop();
}
#[test]
fn exec_failure_is_reported_in_the_terminal() {
// Unlike the Unix fork+exec path (child exits with 1), `CreateProcessW`
// failing is a start error: the IO thread prints the error into the
// terminal and goes into drain mode (same as Zig's `threadEnter`).
let mut f = start(ConfigCommand::Direct(vec![
r"C:\definitely\not\here.exe".into(),
]));
f.wait_text("error starting IO thread");
f.wait_text("This terminal is non-functional");
// Messages are swallowed in drain mode and stop still works.
f.write(b"ignored");
f.stop();
}
#[test]
fn shell_command_runs_through_windir_cmd() {
// `ConfigCommand::Shell` => `%WINDIR%\System32\cmd.exe /C <command>`.
let mut f = start(ConfigCommand::Shell("echo shell-cmd-ok & exit 4".into()));
let (code, _) = f.wait_exit();
assert_eq!(code, 4);
f.wait_text("shell-cmd-ok");
f.stop();
}
#[test]
fn writes_reach_the_child_and_exit_code_follows() {
// Interactive cmd.exe: type a command, then exit with a code.
let mut f = start(cmd(&[]));
f.write(b"echo mk1\r\n");
f.wait_for("command output", |f| {
// Once echoed by conhost with the prompt, once printed by `echo`.
(f.text().matches("mk1").count() >= 2).then_some(())
});
f.write(b"exit 5\r\n");
let (code, _) = f.wait_exit();
assert_eq!(code, 5);
f.stop();
}
#[test]
fn large_write_is_delivered_in_order() {
// ~120 KiB typed into an interactive cmd.exe: exercises the writer
// thread's overlapped WriteFile queue together with the reader.
let mut f = start(cmd(&[]));
f.wait_text(">");
let mut data = Vec::new();
for i in 0..2000 {
data.extend_from_slice(format!("rem {i:05} {}\r\n", "x".repeat(50)).as_bytes());
}
data.extend_from_slice(b"echo done-%OS:~0,1%-end\r\n");
for chunk in data.chunks(4096) {
f.write(chunk);
}
// `%OS%` is Windows_NT: the expansion proves cmd.exe executed the line
// after consuming every preceding byte.
f.wait_text("done-W-end");
f.write(b"exit 0\r\n");
f.wait_exit();
f.stop();
}
/// Not a test by itself: the child process of `resize_is_applied_to_the_pseudo_console`
/// (this test binary re-run on the pseudo console). Prints the console window
/// size on startup and after every line read from stdin; `q` quits.
#[test]
fn child_console_size() {
use std::io::BufRead;
use windows_sys::Win32::System::Console::{
CONSOLE_SCREEN_BUFFER_INFO,
GetConsoleScreenBufferInfo,
GetStdHandle,
STD_OUTPUT_HANDLE,
};
if std::env::var_os("GHOSTTY_WINE_CHILD").is_none() {
return;
}
let report = || {
let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
let ok =
unsafe { GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &raw mut info) };
let w = i32::from(info.srWindow.Right) - i32::from(info.srWindow.Left) + 1;
let h = i32::from(info.srWindow.Bottom) - i32::from(info.srWindow.Top) + 1;
println!("SIZE ok={ok} {w}x{h} END");
};
report();
for line in std::io::stdin().lock().lines() {
if line.unwrap_or_default().trim() == "q" {
break;
}
report();
}
}
#[test]
fn resize_message_resizes_terminal_and_pseudo_console_call_succeeds() {
let mut f = start(cmd(&[]));
f.wait_text(">");
for i in 0..10u32 {
f.send(Message::Resize(size(60 + i, 20 + i)));
}
f.send(Message::Resize(size(100, 40)));
f.wait_for("terminal resize", |f| {
let t = f.state.lock_terminal();
((t.cols, t.rows) == (100, 40)).then_some(())
});
assert_eq!(
*f.renderer.resizes.lock().unwrap().last().unwrap(),
size(100, 40)
);
// The child is still alive and answering after ResizePseudoConsole.
f.write(b"echo after-resize\r\n");
f.wait_for("echo after resize", |f| {
(f.text().matches("after-resize").count() >= 2).then_some(())
});
f.write(b"exit 0\r\n");
f.wait_exit();
f.stop();
}
#[test]
#[cfg_attr(
wine,
ignore = "wine: ResizePseudoConsole is a stub (\"fixme:console:ResizePseudoConsole (100,40) stub, faking success\"), the child never sees the new size"
)]
fn resize_is_applied_to_the_pseudo_console() {
let exe = std::env::current_exe().unwrap().display().to_string();
let mut f = start_with(
ConfigCommand::Direct(vec![
exe.into(),
"--exact".into(),
"child_console_size".into(),
"--nocapture".into(),
]),
|c| {
c.env.insert("GHOSTTY_WINE_CHILD", "1");
},
);
// size(80, 30) is what the pty was created with.
f.wait_text("SIZE ok=1 80x30 END");
f.send(Message::Resize(size(100, 40)));
f.wait_for("terminal resize", |f| {
let t = f.state.lock_terminal();
((t.cols, t.rows) == (100, 40)).then_some(())
});
// ResizePseudoConsole is synchronous with the message; ask the child.
f.write(b"\r\n");
f.wait_text("SIZE ok=1 100x40 END");
f.write(b"q\r\n");
let (code, _) = f.wait_exit();
assert_eq!(code, 0);
f.stop();
}
#[test]
fn stop_terminates_a_running_child() {
let mut f = start(cmd(&[]));
f.wait_text(">");
let started = Instant::now();
f.stop();
assert!(
started.elapsed() < Duration::from_secs(10),
"stop took {:?}",
started.elapsed()
);
}
#[test]
fn writes_after_exit_are_ignored_and_thread_stays_alive_until_stopped() {
let mut f = start(cmd(&["/c", "exit 0"]));
let (code, _) = f.wait_exit();
assert_eq!(code, 0);
f.write(b"data nobody reads");
f.send(Message::Focused(true));
f.send(Message::Resize(size(90, 25)));
f.wait_for("resize after exit", |f| {
(f.state.lock_terminal().cols == 90).then_some(())
});
f.stop();
}