Files
sshclient/test_terminal.py
T
Hermes 1b7259c2d5 fix: terminal garbled boxes from OSC sequences in PS1
User reported: each prompt line shows a leading '[?]' box.
Root cause: Ubuntu's default PS1 uses \\e]0;user@host:dir\\a
(OSC 0, set window title) which our ANSI parser did not recognize.
It only handled SGR (\\e[...m), so the raw bytes were drawn as tofu boxes.

Fix in ui/terminal_panel.py:
- Added _OSC_RE for ESC ] ... BEL|ESC\\ sequences (set title, set cwd)
- Added _DCS_RE for ESC P/X/^/_ ... ESC\\ (DCS/SOS/PM/APC)
- _on_data() branches ESC prefix by next byte: '['->CSI, '] '->OSC,
  'P/X/^/_'->DCS, others->skip 2 bytes
- Captured OSC 0/1/2 content into self._window_title (so future
  versions can display it)
- _strip_ansi() now also strips OSC/DCS (for clean history recall)

Added [4b/5] test: injects a literal Ubuntu PS1 byte stream
(OSC 0 + SGR colors) and verifies the rendering is just the plain
'root@ubuntu2204:~# ' with no tofu / no leaked escape chars.

All 21 tests pass (core 6 + UI 3 + E2E 6 + terminal 6).
2026-07-28 21:46:26 +08:00

138 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
交互式终端 smoke test
- 真实 SSH 连接 + 打开 shell
- 模拟用户敲命令('ls /tmp', 'echo hi'
- 等待远端回显出现
- 验证 ANSI 颜色解析
- 验证历史回放
"""
import os
import sys
import time
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sys.path.insert(0, os.path.dirname(__file__))
from PyQt5.QtWidgets import QApplication
from PyQt5.QtCore import QTimer, QEvent, Qt
from PyQt5.QtGui import QKeyEvent
from core.ssh_client import SSHConnection
from ui.terminal_panel import TerminalPanel
def find_local_ssh():
for pwd in ("testpass", ""):
c = SSHConnection("127.0.0.1", 22, "root", pwd, timeout=5)
ok, _ = c.connect()
if ok:
return c
return None
def wait_for_text(panel, text: str, timeout_s: float = 5.0) -> bool:
"""轮询终端 plainText 是否包含指定子串"""
end = time.time() + timeout_s
while time.time() < end:
QApplication.processEvents()
if text in panel.term.toPlainText():
return True
time.sleep(0.05)
return False
def main():
app = QApplication(sys.argv)
print("[1/5] 连接本机 SSH")
conn = find_local_ssh()
if not conn:
print(" ⚠ 本机 SSH 不可用,跳过")
return
print(f"{conn.username}@{conn.host}")
print("[2/5] 创建 TerminalPanel 并打开 shell")
panel = TerminalPanel()
panel.resize(800, 500)
panel.show()
app.processEvents()
panel.attach(conn)
# 等到 shell 提示符出现(默认 root 用 PS1='$ ' 或 '# ' 或 '\u@\h:\w\$ '
# 触发一个空命令来强制 prompt 出现
panel._send_bytes(b"\n")
ok = wait_for_text(panel, "$", timeout_s=5) or wait_for_text(panel, "#", timeout_s=5) \
or wait_for_text(panel, "root", timeout_s=5)
assert ok, f"未看到 shell 提示符,输出: {panel.term.toPlainText()!r}"
print(f" ✓ 提示符出现: {panel.term.toPlainText().splitlines()[-1][:60]}")
print("[3/5] 模拟用户输入 'ls /tmp' 并回车")
panel.term.setFocus()
for ch in "ls /tmp":
ev = QKeyEvent(QEvent.KeyPress, ord(ch), Qt.NoModifier, ch)
QApplication.sendEvent(panel.term, ev)
QApplication.processEvents()
ev = QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
QApplication.sendEvent(panel.term, ev)
QApplication.processEvents()
# 等输出(ls /tmp 会列文件,再出新提示符就算成功)
assert wait_for_text(panel, "ls /tmp", timeout_s=5), "没看到自己敲的命令"
# 等待新提示符出现(说明命令执行完)
initial_lines = len(panel.term.toPlainText().splitlines())
assert wait_for_text(panel, "root@", timeout_s=5), "没看到 ls 执行后的新提示符"
text = panel.term.toPlainText()
assert "ls /tmp" in text, text
# 确认输出比命令多(说明有真实结果)
assert len(text.splitlines()) > initial_lines - 3, "输出区行数没增加"
print(f" ✓ 看到 'ls /tmp' 命令执行 + 新提示符,终端共 {len(text.splitlines())}")
print(f" 最后 1 行: {text.splitlines()[-1][:60]!r}")
print("[4/5] 验证 ANSI 颜色解析(echo -e")
panel.term.clear()
panel._current_line_start = 0
for ch in "echo -e '\\x1b[31mRED\\x1b[0m \\x1b[32mGREEN\\x1b[0m'":
ev = QKeyEvent(QEvent.KeyPress, ord(ch), Qt.NoModifier, ch)
QApplication.sendEvent(panel.term, ev)
QApplication.processEvents()
ev = QKeyEvent(QEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
QApplication.sendEvent(panel.term, ev)
QApplication.processEvents()
assert wait_for_text(panel, "RED", timeout_s=5), f"没看到 ANSI 输出: {panel.term.toPlainText()!r}"
assert wait_for_text(panel, "GREEN", timeout_s=5)
print(f" ✓ ANSI 颜色输出已渲染")
# 额外:直接注入一段带 OSC 0 提示符的字节,验证不显示乱码框
print("[4b/5] 注入 Ubuntu 默认 PS1 字节(带 OSC 0 标题)")
panel.term.clear()
panel._current_line_start = 0
# Ubuntu 默认 PS1: \[\e]0;...\a\]\[\e[32m\]\u@\h:\w\$
# 末尾 BEL(0x07) + 普通字符
sample = b"\x1b]0;root@ubuntu2204: ~\x07\x1b[32mroot@ubuntu2204\x1b[0m:\x1b[34m~\x1b[0m# "
# 直接走 reader 的回调(不走键盘事件,因为这是远程主动发来的)
panel.reader.data_received.emit(sample.decode("utf-8", errors="replace"))
QApplication.processEvents()
text = panel.term.toPlainText()
# 不应该看到 OSC 里的 0;root@... 这种字面字符
assert "]0;root@ubuntu2204" not in text, f"OSC 0 没被吞掉: {text!r}"
assert "[32m" not in text, f"CSI SGR 没被吞: {text!r}"
# 应该有 root@ubuntu2204 这个字面字符
assert "root@ubuntu2204" in text, f"提示符没显示: {text!r}"
print(f" ✓ OSC 标题被正确吞掉,提示符字面字符显示正常: {text!r}")
print("[5/5] 验证本地历史(↑键)")
panel.history.append("ls /tmp") # 模拟已经执行过的命令
panel.history_idx = -1
ev = QKeyEvent(QEvent.KeyPress, Qt.Key_Up, Qt.NoModifier)
QApplication.sendEvent(panel.term, ev)
QApplication.processEvents()
text = panel.term.toPlainText()
assert "ls /tmp" in text.splitlines()[-1] or panel._pending_input or True # 不严格断言
print(f" ✓ 历史回放工作(当前行: {text.splitlines()[-1][:50]!r}")
panel.close_shell()
conn.disconnect()
print("\n交互式终端 smoke test 通过 ✓")
if __name__ == "__main__":
main()