use std::process::Command;
fn hey_bin() -> String {
for p in &["target/release/hey", "target/debug/hey"] {
if let Ok(abs) = std::fs::canonicalize(p) {
return abs.to_string_lossy().into_owned();
}
}
panic!("hey binary not found; run cargo build first");
}
fn run_pty_test(bin: &str, port: u16, ctrl_c_pattern: &str) -> (i32, bool, String) {
let script = format!(
r#"
import pty, os, sys, time, termios, fcntl, socket, threading, tempfile, select
PORT = {port}
BIN = r"{bin}"
PATTERN = "{pattern}"
tmp_home = tempfile.mkdtemp(prefix='hey_test_')
os.environ['HOME'] = tmp_home
os.environ['XDG_CONFIG_HOME'] = tmp_home + '/.config'
def hanging_server(port, ready):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('127.0.0.1', port))
srv.listen(1)
ready.set()
conn, _ = srv.accept()
time.sleep(60)
ready = threading.Event()
threading.Thread(target=hanging_server, args=(PORT, ready), daemon=True).start()
ready.wait(timeout=2)
master, slave = pty.openpty()
pid = os.fork()
if pid == 0:
os.setsid()
os.dup2(slave, 0); os.dup2(slave, 1); os.dup2(slave, 2)
os.close(master); os.close(slave)
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
os.chdir('/tmp')
os.execv(BIN, ['hey',
'--base-url', 'http://127.0.0.1:' + str(PORT) + '/v1',
'--model', 'test', '--api-key', 'dummy',
'--no-mcp', '--no-skills'])
os._exit(127)
else:
os.close(slave)
time.sleep(1.0)
os.write(master, b'hi\r')
time.sleep(1.0)
if PATTERN == 'quick':
# 快速双击(10ms 间隔,都在流式窗口内被信号 handler 捕获)
os.write(master, b'\x03')
time.sleep(0.01)
os.write(master, b'\x03')
elif PATTERN == 'slow':
# 慢速:1次取消流式 → 等 prompt → 2次在 prompt 退出
os.write(master, b'\x03')
time.sleep(1.0)
os.write(master, b'\x03')
time.sleep(0.3)
os.write(master, b'\x03')
_, status = os.waitpid(pid, 0)
exit_code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1
output = b''
try:
while True:
output += os.read(master, 4096)
except OSError:
pass
term_ok = False
try:
attrs = termios.tcgetattr(master)
lflag = attrs[3]
term_ok = bool(lflag & termios.ISIG) and bool(lflag & termios.ICANON) and bool(lflag & termios.ECHO)
print('termios: ISIG=%s ICANON=%s ECHO=%s' % (bool(lflag & termios.ISIG), bool(lflag & termios.ICANON), bool(lflag & termios.ECHO)))
except Exception as e:
print('tcgetattr: %s' % e)
os.close(master)
print('exit_code=%d' % exit_code)
print('output_tail=%r' % output[-200:])
print('TERM_OK=%s' % term_ok)
sys.exit(0)
"#,
port = port,
bin = bin,
pattern = ctrl_c_pattern,
);
let output = Command::new("python3")
.arg("-c")
.arg(&script)
.output()
.expect("failed to run python3");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let exit_code = output.status.code().unwrap_or(-1);
let term_ok = stdout.contains("TERM_OK=True");
(exit_code, term_ok, stdout)
}
#[test]
fn quick_double_ctrl_c_exits_130() {
let bin = hey_bin();
let port = 18900 + (std::process::id() % 1000) as u16;
let (_, term_ok, stdout) = run_pty_test(&bin, port, "quick");
println!("{stdout}");
assert!(
stdout.contains("exit_code=130"),
"expected exit 130: {stdout}"
);
assert!(term_ok, "terminal not restored: {stdout}");
}
#[test]
fn slow_ctrl_c_restores_terminal() {
let bin = hey_bin();
let port = 19000 + (std::process::id() % 1000) as u16;
let (_hey_exit, term_ok, stdout) = run_pty_test(&bin, port, "slow");
println!("{stdout}");
assert!(
stdout.contains("exit_code=0") || stdout.contains("exit_code=130"),
"unexpected exit: {stdout}"
);
assert!(term_ok, "terminal not restored: {stdout}");
}