# -*- coding: utf-8 -*- """端到端验证:启动**打包后的 EXE**,配对,然后调 feature_list。 这是唯一能验出"Features 没被构造"这个 bug 的方式 —— 之前的测试都自己 new 一个 Features,所以永远测不出来(真机上功能页是死的)。 """ from __future__ import annotations import json import os import shutil import socket import subprocess import sys import tempfile import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe" PORT = 8871 PASSED = 0 FAILED = 0 def check(name, ok, detail=""): global PASSED, FAILED if ok: PASSED += 1 print(f" [PASS] {name}") else: FAILED += 1 print(f" [FAIL] {name} {detail}") class Client: def __init__(self, host, port, timeout=10.0, source_ip=None): if source_ip: # 绑定一个具体的源地址,用来模拟"另一台机器" # (Windows 上整个 127.0.0.0/8 都是回环) self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(timeout) self.sock.bind((source_ip, 0)) self.sock.connect((host, port)) else: self.sock = socket.create_connection((host, port), timeout=timeout) self.sock.settimeout(timeout) self.buf = b"" def send(self, obj): self.sock.sendall((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")) def recv(self, timeout=12.0): deadline = time.time() + timeout while b"\n" not in self.buf: if time.time() > deadline: raise TimeoutError("等回复超时") chunk = self.sock.recv(65536) if not chunk: raise ConnectionError("连接断开") self.buf += chunk line, _, self.buf = self.buf.partition(b"\n") return json.loads(line.decode("utf-8")) def call(self, obj, timeout=12.0): self.send(obj) return self.recv(timeout) def close(self): try: self.sock.close() except OSError: pass def main() -> int: if not EXE.is_file(): print(f" 找不到打包好的 EXE: {EXE}") print(" 先跑 windows\\build_exe.ps1") return 1 # 用独立的数据目录,别碰用户真实配置 work = Path(tempfile.mkdtemp(prefix="lr_e2e_")) exe_dir = EXE.parent # 先把真实 data 目录挪开(EXE 的 CWD 决定 data 位置) backup = None real_data = exe_dir / "data" if real_data.exists(): backup = exe_dir / f"data_backup_{int(time.time())}" shutil.move(str(real_data), str(backup)) print(f" 已备份真实 data 到 {backup.name}") proc = None try: proc = subprocess.Popen( # --add-device:静默运行 + 临时开放配对。 # 不能用 --minimized —— 全新数据目录会弹设置窗口卡住。 [str(EXE), "--port", str(PORT), "--add-device", "10"], cwd=str(exe_dir), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(f" 已启动 EXE(PID {proc.pid}),等待监听…") code = None for _ in range(40): time.sleep(0.5) settings = exe_dir / "data" / "settings.json" if settings.is_file(): try: code = json.loads(settings.read_text(encoding="utf-8")).get( "pairing_code") except Exception: # noqa: BLE001 pass if code: try: probe = socket.create_connection(("127.0.0.1", PORT), timeout=1) probe.close() break except OSError: continue check("EXE 起来了并读到配对码", bool(code), f"code={code}") if not code: return 1 print() print("一、配对") client = Client("127.0.0.1", PORT) print() print("一b、★ 配对防爆破(必须在成功配对之前测," "否则配对窗口已经关了)") # 从 127.0.0.2 发起 —— 限速是按 IP 的, # 用同一个 IP 会把后面的合法配对一起锁掉 breaker = Client("127.0.0.1", PORT, source_ip="127.0.0.2") wrong = "000000" if code != "000000" else "111111" limited = False for i in range(12): r = breaker.call({"cmd": "pair", "deviceId": f"brute-{i}", "deviceName": "爆破", "code": wrong}, timeout=10) if not r.get("ok") and "过多" in str(r.get("error", "")): limited = True print(f" 第 {i + 1} 次开始被限速") break check("连续错码会被限速", limited, f"12 次都没触发限速,最后一次: {r}") breaker.close() # 限速状态在内存里,重启就清空。 # 重启既能让后续的合法配对不被挡住,也顺带验证了 # "限速不会写进磁盘、重启即恢复"。 print(" 重启被控端以清空限速…") proc.terminate() try: proc.wait(timeout=10) except Exception: # noqa: BLE001 proc.kill() time.sleep(1.5) proc = subprocess.Popen( [str(EXE), "--port", str(PORT), "--add-device", "10"], cwd=str(exe_dir), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) restarted = False for _ in range(30): time.sleep(0.5) try: s = socket.create_connection(("127.0.0.1", PORT), timeout=1) s.close() restarted = True break except OSError: continue check("重启后被控端重新在监听", restarted) time.sleep(1.0) # 重启之后必须重新建连接(旧的 socket 已经断了) client = Client("127.0.0.1", PORT) reply = client.call({"cmd": "pair", "deviceId": "e2e-test", "deviceName": "端到端测试", "code": code}) check("配对成功", reply.get("ok") is True, str(reply)[:120]) print() print("二、★ 功能列表(这是之前完全失败的那一项)") reply = client.call({"cmd": "feature_list"}, timeout=25) ok = reply.get("ok") is True features = reply.get("features") or [] check("feature_list 返回 ok", ok, str(reply.get("error"))[:120]) check("功能数量 > 100", len(features) > 100, f"{len(features)} 个") if features: groups = sorted({f.get("group", "?") for f in features}) print(f" 共 {len(features)} 个功能,{len(groups)} 个分组") print(f" 分组: {'、'.join(groups)}") print() print("三、★ 执行一个功能") reply = client.call({"cmd": "feature_run", "name": "world_clock", "params": {}}, timeout=20) check("feature_run 返回 ok", reply.get("ok") is True, str(reply)[:120]) note = str(reply.get("note") or "") check("世界时钟返回了内容", "北京" in note, note[:80]) if note: print(f" {note.replace(chr(10), ' | ')}") print() print("四、★ 文件功能 + 越权拒绝") reply = client.call({"cmd": "feature_run", "name": "file_info", "params": {"path": "C:\\Windows\\win.ini"}}, timeout=15) check("越权路径被拒绝", reply.get("ok") is False, f"竟然成功了: {str(reply)[:100]}") reply = client.call({"cmd": "feature_run", "name": "note_add", "params": {"text": "端到端测试笔记"}}, timeout=15) check("记笔记成功", reply.get("ok") is True, str(reply)[:100]) print() print("五、★ 相对路径不能拉走 settings.json") reply = client.call({"cmd": "file_pull", "path": "data/settings.json"}, timeout=15) check("拒绝拉取 data/settings.json", reply.get("ok") is False, f"竟然成功了: {str(reply)[:100]}") print() print("七、★ 分块超限") big = "A" * (300 * 1024) try: client.send({"cmd": "file_push", "seq": 0, "last": False, "name": "big.bin", "data": big}) client.send({"cmd": "file_push", "seq": 1, "last": False, "name": "big.bin", "data": big}) r = client.recv(timeout=8) except Exception as exc: # noqa: BLE001 r = {"error": str(exc)} check("超限不会让服务端崩(还活着)", True) reply = client.call({"cmd": "ping"}, timeout=10) check("服务端仍然响应 ping", reply.get("ok") is True, str(reply)[:80]) client.close() finally: if proc is not None: try: proc.terminate() proc.wait(timeout=8) except Exception: # noqa: BLE001 try: proc.kill() except Exception: # noqa: BLE001 pass time.sleep(1.0) # 还原真实 data try: test_data = exe_dir / "data" if test_data.exists(): shutil.rmtree(test_data, ignore_errors=True) if backup is not None and backup.exists(): shutil.move(str(backup), str(real_data)) print(f" 已还原真实 data") except Exception as exc: # noqa: BLE001 print(f" !! 还原 data 失败: {exc}") shutil.rmtree(work, ignore_errors=True) print() print("=" * 62) print(f"通过 {PASSED} 项,失败 {FAILED} 项") return 1 if FAILED else 0 if __name__ == "__main__": sys.exit(main())