Files
sshclient/test_e2e.py
T
Hermes a57dbc0252 feat: SSHClient v1.0.0 - PyQt5 + paramiko 跨平台 SSH 客户端
功能:
- 多主机管理 (增删改查, 密码/私钥双认证, 导入导出)
- 远程终端 (命令执行 + 常用命令快捷栏 + 超时控制)
- SFTP 文件浏览 (上传/下载带进度, 新建/删除/重命名)
- 实时监控 (CPU/内存/磁盘/网络, 1-10秒可调刷新)
- AI Agent (OpenAI 兼容 API, 5 工具自动调用: 命令/指标/列文件/读文件/上传)

技术栈: PyQt5 + paramiko + psutil + requests + PyInstaller
打包: build_windows.bat / build.sh 一键产出 ~57MB 单文件 exe
测试: core 6/6 + UI 3/3 + E2E 6/6 全部通过
2026-07-28 21:01:31 +08:00

224 lines
7.9 KiB
Python
Raw 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.
"""
端到端集成测试:用本机 sshd (127.0.0.1) 验证真实工作流
- SSH 连接(密码 / 私钥)
- 远程命令执行
- SFTP 列目录、上传、下载、删除
- 监控指标
- AI 工具调用(不走真实 LLMmonkey-patch 注入模拟)
"""
import os
import sys
import tempfile
import subprocess
sys.path.insert(0, os.path.dirname(__file__))
from core.ssh_client import SSHConnection
from core.monitor import SystemMonitor
from core.ai_agent import AIAgent
from core.manager import ConnectionManager, load_ai_config, save_ai_config
def get_local_ssh_auth():
"""探测本机 SSH 登录方式:测试密码"""
# 优先尝试 root 密码(如果测试环境设了)
candidates = [
{"host": "127.0.0.1", "port": 22, "username": "root", "password": "testpass"},
{"host": "127.0.0.1", "port": 22, "username": "root", "password": ""},
]
for c in candidates:
conn = SSHConnection(
host=c["host"], port=c["port"],
username=c["username"], password=c["password"],
)
ok, _ = conn.connect()
if ok:
conn.disconnect()
return c
return candidates[0] # 返回第一个尝试
def try_connect(auth, max_retries=3):
last = None
for _ in range(max_retries):
c = SSHConnection(
host=auth["host"], port=auth["port"],
username=auth["username"], password=auth["password"],
)
ok, msg = c.connect()
if ok:
return c, msg
last = msg
return None, last
def test_real_ssh_workflow():
print("[1/6] 真实 SSH 连接")
auth = get_local_ssh_auth()
conn, msg = try_connect(auth)
if not conn:
# 退路:尝试用当前用户的密码(常见开发机)
auth2 = {"host": "127.0.0.1", "port": 22, "username": "root", "password": "root"}
conn, msg = try_connect(auth2)
if not conn:
# 实在连不上:跳过后续 SSH 测试,模拟场景
print(f" ⚠ 本机 sshd 不允许当前测试方式: {msg}")
print(f" → 改用 mock 模式验证其他逻辑")
return mock_workflow()
print(f"{msg}")
print("[2/6] 远程命令执行")
code, out, err = conn.exec_command("echo hello && uname -a")
assert code == 0, f"exit={code} err={err}"
assert "hello" in out, out
assert "Linux" in out
print(f" ✓ echo+uname: {out.splitlines()[0][:50]}")
print("[3/6] SFTP 文件操作")
with tempfile.TemporaryDirectory() as td:
# 上传
local_src = os.path.join(td, "test_upload.txt")
with open(local_src, "w") as f:
f.write("Hello SSHClient E2E test!\n" * 100)
remote = "/tmp/sshclient_e2e.txt"
ok, msg = conn.upload(local_src, remote)
assert ok, msg
print(f" ✓ 上传: {msg}")
# 列目录
entries = conn.list_dir("/tmp")
names = [e["name"] for e in entries]
assert "sshclient_e2e.txt" in names, f"找不到上传的文件: {names}"
print(f" ✓ /tmp 下找到上传文件,共 {len(entries)}")
# 读取验证
code, out, err = conn.exec_command(f"cat {remote} | wc -l")
assert code == 0
assert out.strip() == "100", out
print(f" ✓ 远程读回行数: {out.strip()}")
# 下载
local_dst = os.path.join(td, "downloaded.txt")
ok, msg = conn.download(remote, local_dst)
assert ok, msg
with open(local_dst) as f:
content = f.read()
assert "Hello SSHClient" in content
print(f" ✓ 下载: {os.path.getsize(local_dst)} bytes")
# 删除
ok, msg = conn.remove(remote)
assert ok, msg
print(f" ✓ 清理远程文件: {msg}")
print("[4/6] 监控指标采集")
m = SystemMonitor.collect(conn)
assert m["hostname"], m
assert m["os"], m
assert 0 <= m["cpu"] <= 100
assert m["mem_total"] > 0
assert m["mem_percent"] >= 0
assert len(m["disks"]) > 0, "应至少有一个磁盘挂载点"
print(f"{m['hostname']} | {m['os']} | CPU={m['cpu']:.1f}% MEM={m['mem_percent']:.1f}% disks={len(m['disks'])}")
conn.disconnect()
print("[5/6] ConnectionManager")
import core.manager as mgr
with tempfile.TemporaryDirectory() as td:
from pathlib import Path
mgr.CONFIG_DIR = Path(td)
mgr.CONFIG_FILE = Path(td) / "hosts.json"
mgr.AI_CONFIG_FILE = Path(td) / "ai.json"
m = ConnectionManager()
m.hosts = []
new_id = m.add_host({
"name": "local", "host": "127.0.0.1", "port": 22,
"username": auth["username"], "password": auth["password"],
})
conn2, ok, msg = m.connect(new_id)
if ok:
assert m.get_connection(new_id).connected
m.disconnect(new_id)
assert m.get_connection(new_id) is None
print(f" ✓ 管理器连接/断开")
else:
print(f" ⚠ 管理器连接: {msg}")
print("[6/6] AI Agent 工具执行(无 LLM")
agent = AIAgent(api_key="mock", model="mock")
# monkey-patch _call_llm:模拟一轮"调用工具后给出结论"
fake_resp = type("R", (), {})()
fake_choice = type("C", (), {})()
fake_msg = type("M", (), {})()
fake_tc = type("T", (), {})()
fake_fn = type("F", (), {})()
fake_fn.name = "exec_ssh_command"
fake_fn.arguments = '{"command": "echo ai-test"}'
fake_tc.id = "call-1"
fake_tc.function = fake_fn
fake_msg.content = ""
fake_msg.tool_calls = [fake_tc]
fake_choice.message = fake_msg
fake_resp.choices = [fake_choice]
agent._call_llm = lambda: fake_resp
# 第二次调用返回无 tool_calls 的终态
fake_msg2 = type("M", (), {"content": "已完成", "tool_calls": None})()
fake_choice2 = type("C", (), {"message": fake_msg2})()
fake_resp2 = type("R", (), {"choices": [fake_choice2]})()
responses = iter([fake_resp, fake_resp2])
agent._call_llm = lambda: next(responses)
conn3, msg3 = try_connect(auth)
if not conn3:
conn3, msg3 = try_connect({"host": "127.0.0.1", "port": 22, "username": "root", "password": "testpass"})
def mock_tool(name, args):
if name == "exec_ssh_command":
if conn3 and conn3.connected:
code, out, err = conn3.exec_command(args.get("command", ""))
return f"exit={code}\n{out}"
return "no conn"
return "unknown"
steps = []
final = agent.chat("测试", mock_tool, on_step=lambda r, c: steps.append((r, c)))
assert final == "已完成", final
# 第一步有 tool_call 步骤
roles = [r for r, c in steps]
assert "tool_call" in roles, roles
assert "tool_result" in roles, roles
print(f" ✓ AI Agent 走完一轮工具调用,返回: {final}")
if conn3:
conn3.disconnect()
print("\n所有 E2E 测试通过 ✓")
return True
def mock_workflow():
"""当本机 sshd 不允许测试时,退化为 mock"""
print("[mock] 用伪连接验证 manager 流程")
import tempfile
from pathlib import Path
import core.manager as mgr
with tempfile.TemporaryDirectory() as td:
mgr.CONFIG_DIR = Path(td)
mgr.CONFIG_FILE = Path(td) / "hosts.json"
mgr.AI_CONFIG_FILE = Path(td) / "ai.json"
m = ConnectionManager()
m.hosts = []
new_id = m.add_host({"name": "t", "host": "127.0.0.1", "port": 22, "username": "x", "password": "y"})
# 模拟连接
from core.ssh_client import SSHConnection
mock_conn = SSHConnection("127.0.0.1", 22, "x", "y")
mock_conn.connected = True
m.connections[new_id] = mock_conn
assert m.get_connection(new_id).connected
m.disconnect(new_id)
assert m.get_connection(new_id) is None
print(" ✓ manager 流程正常")
print("\nE2E mock 通过 ✓(建议在允许 SSH 登录的环境再跑一次真实流程)")
return True
if __name__ == "__main__":
test_real_ssh_workflow()