docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
191 lines
8.9 KiB
Python
191 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""真机图形界面验收:在真实设备模式下驱动界面完成绘制→规划→播放,并截图留证。
|
|
|
|
与 tests/real_device_e2e.py 的区别:这个是**通过浏览器点击界面**完成的(端到端,
|
|
包含前端),而 real_device_e2e.py 是直接走 WS 协议。
|
|
|
|
./.venv/bin/python tests/real_gui_acceptance.py --confirm --url http://127.0.0.1:8790/
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "tests"))
|
|
|
|
from ui_acceptance import CDP, launch_chrome # noqa: E402
|
|
|
|
PASS = 0
|
|
FAIL = 0
|
|
FAILURES: list[str] = []
|
|
|
|
|
|
def check(name: str, ok: bool, detail: str = "") -> None:
|
|
global PASS, FAIL
|
|
if ok:
|
|
PASS += 1
|
|
print(f" \033[32m✓\033[0m {name}")
|
|
else:
|
|
FAIL += 1
|
|
FAILURES.append(f"{name} {detail}")
|
|
print(f" \033[31m✗\033[0m {name} {detail}")
|
|
|
|
|
|
def main() -> int:
|
|
global PASS, FAIL
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--confirm", action="store_true", help="确认会真的改变设备定位")
|
|
parser.add_argument("--url", default="http://127.0.0.1:8790/")
|
|
parser.add_argument("--out", default="/tmp/ui-real-acceptance")
|
|
parser.add_argument("--port", type=int, default=9466)
|
|
parser.add_argument("--wait-connect", type=float, default=90.0)
|
|
args = parser.parse_args()
|
|
if not args.confirm:
|
|
print("真机测试会改变设备定位,请加 --confirm")
|
|
return 2
|
|
|
|
out_dir = Path(args.out)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
print("\033[1m真机图形界面验收\033[0m")
|
|
|
|
process, ws_url = launch_chrome(1440, 900, args.port)
|
|
cdp = CDP(ws_url, timeout=180)
|
|
try:
|
|
cdp.call("Page.enable")
|
|
cdp.call("Runtime.enable")
|
|
cdp.call("Emulation.setDeviceMetricsOverride",
|
|
{"width": 1440, "height": 900, "deviceScaleFactor": 2, "mobile": False})
|
|
cdp.call("Page.navigate", {"url": args.url})
|
|
time.sleep(4.0)
|
|
|
|
check("界面已连接服务端",
|
|
"已连接" in str(cdp.eval("document.querySelector('#connPill').textContent")),
|
|
str(cdp.eval("document.querySelector('#connPill').textContent")))
|
|
device_text = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()"))
|
|
check("设备卡片识别到真机", "iPhone" in device_text, device_text[:160])
|
|
print(f" 设备卡片:{device_text[:120]}")
|
|
cdp.shot(out_dir / "01-device-found.png")
|
|
|
|
# 点「连接设备」
|
|
cdp.eval("(function(){var b=document.querySelector('#btnDeviceConnect');if(b)b.click();return !!b})()")
|
|
print(" → 正在连接设备(iOS 17+ 会建立 RSD 隧道,最长约 90 秒)…")
|
|
connected = False
|
|
deadline = time.time() + args.wait_connect
|
|
while time.time() < deadline:
|
|
time.sleep(1.0)
|
|
card = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText:''})()"))
|
|
if "模拟中" in card or "已连接" in card and "虚拟定位" in card:
|
|
if "模拟中" in card or "就绪" in card:
|
|
connected = True
|
|
break
|
|
card_final = str(cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()"))
|
|
check("设备连接成功(卡片显示虚拟定位状态)", connected, card_final[:200])
|
|
print(f" 连接后卡片:{card_final[:160]}")
|
|
cdp.shot(out_dir / "02-connected.png")
|
|
|
|
# 绘制 3 个途经点
|
|
cdp.eval("(function(){var b=document.querySelector('#toolAdd');if(b)b.click();return !!b})()")
|
|
time.sleep(0.4)
|
|
box = json.loads(cdp.eval("(function(){var r=document.querySelector('#map').getBoundingClientRect();"
|
|
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height})})()"))
|
|
for fx, fy in ((0.28, 0.30), (0.50, 0.48), (0.74, 0.36)):
|
|
cdp.click(box["x"] + box["w"] * fx, box["y"] + box["h"] * fy)
|
|
time.sleep(0.45)
|
|
wp = cdp.eval("(function(){return document.querySelectorAll('#wpList .wp-item, #wpList li, #wpList [data-index]').length})()")
|
|
check("地图点选生成途经点", int(wp or 0) >= 3, f"途经点={wp}")
|
|
|
|
# 规划路线
|
|
cdp.eval("(function(){var b=document.querySelector('#btnBuildRoute');if(b)b.click();return !!b})()")
|
|
distance = "—"
|
|
for _ in range(40):
|
|
time.sleep(0.5)
|
|
distance = str(cdp.eval("document.querySelector('#statDistance').textContent"))
|
|
if distance not in ("—", "-", "", "0"):
|
|
break
|
|
check("OSRM 路线规划成功", distance not in ("—", "-", "", "0"), f"总距离={distance!r}")
|
|
cdp.shot(out_dir / "03-route.png")
|
|
|
|
# 倍速滑块是对数刻度(0.1x ~ 50x),按 Geo.speedToSlider 反算 1.0x 对应的滑块位置
|
|
slider_value = int(round((__import__("math").log(1.0 / 0.1) / __import__("math").log(50.0 / 0.1)) * 1000))
|
|
cdp.eval("(function(){var s=document.querySelector('#speedRange');if(!s)return 'no';"
|
|
f"s.value={slider_value};s.dispatchEvent(new Event('input',{{bubbles:true}}));"
|
|
"s.dispatchEvent(new Event('change',{bubbles:true}));return s.value})()")
|
|
time.sleep(0.8)
|
|
speed_label = str(cdp.eval("document.querySelector('#speedValue').textContent"))
|
|
check("倍速已设为 1.0x(对数滑块)", "1.0x" in speed_label or "1x" in speed_label, speed_label)
|
|
|
|
# 播放
|
|
cdp.eval("(function(){var b=document.querySelector('#btnPlay');if(b)b.click();return !!b})()")
|
|
progressed = False
|
|
progress_text = ""
|
|
for _ in range(30):
|
|
time.sleep(0.5)
|
|
progress_text = str(cdp.eval("document.querySelector('#chipProgress').textContent"))
|
|
coord = str(cdp.eval("document.querySelector('#chipCoord').textContent"))
|
|
if any(ch.isdigit() for ch in progress_text) and "0%" not in progress_text and "0 %" not in progress_text:
|
|
progressed = True
|
|
break
|
|
check("真机播放推进", progressed, f"progress={progress_text!r}")
|
|
coord = str(cdp.eval("document.querySelector('#chipCoord').textContent"))
|
|
check("界面显示实时下发坐标", any(ch.isdigit() for ch in coord), f"coord={coord!r}")
|
|
print(f" 实时状态:{progress_text} | {coord}")
|
|
cdp.shot(out_dir / "04-playing-real-direct.png")
|
|
|
|
# 日志面板展开,确认有真实下发日志
|
|
cdp.eval("(function(){var b=document.querySelector('#btnToggleLog');if(b)b.click();return !!b})()")
|
|
time.sleep(1.2)
|
|
log_text = str(cdp.eval("(function(){var p=document.querySelector('#logBody');return p?p.innerText:''})()"))
|
|
check("日志面板记录了真机下发", "下发" in log_text or "定位" in log_text, log_text[-200:].replace("\n", " | "))
|
|
cdp.shot(out_dir / "05-logs-real.png")
|
|
|
|
# 停止并恢复真实定位
|
|
cdp.eval("(function(){var b=document.querySelector('#btnStop');if(b)b.click();return !!b})()")
|
|
time.sleep(2.5)
|
|
state = str(cdp.eval("document.querySelector('#chipPlayState').textContent"))
|
|
check("停止后回到未播放", "未播放" in state or "已停止" in state or "已结束" in state, state)
|
|
cdp.shot(out_dir / "06-stopped.png")
|
|
|
|
errors = []
|
|
for event in cdp.events:
|
|
if event.get("method") == "Runtime.exceptionThrown":
|
|
detail = event["params"]["exceptionDetails"]
|
|
errors.append(f"{detail.get('text')} {(detail.get('exception') or {}).get('description', '')[:160]}")
|
|
elif event.get("method") == "Runtime.consoleAPICalled" and event["params"].get("type") == "error":
|
|
parts = [str(a.get("value", a.get("description", "")))[:140] for a in event["params"].get("args", [])]
|
|
errors.append("console.error: " + " ".join(parts))
|
|
check("真机流程中页面无 JS 报错", not errors, " | ".join(errors[:3]))
|
|
except Exception as exc:
|
|
FAIL += 1
|
|
FAILURES.append(f"异常 {exc}")
|
|
print(f" \033[31m✗\033[0m 异常:{exc.__class__.__name__}: {exc}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
finally:
|
|
try:
|
|
cdp.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
os.killpg(os.getpgid(process.pid), 15)
|
|
except Exception:
|
|
process.terminate()
|
|
|
|
print(f"\n{'=' * 56}")
|
|
print(f"真机图形界面验收:通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
|
|
print(f"截图目录:{out_dir}")
|
|
for item in FAILURES:
|
|
print(" -", item[:200])
|
|
return 0 if FAIL == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|