docs: 新增 Windows 移植交接文档

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 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""图形界面验收:用真实 Chrome 驱动界面完成一整套操作并截图。
做的是**图形界面级**验收:在页面上真实点击「添加途经点」→ 在地图上点三个点 →
点「加载 OSRM 路线」→ 点「播放」→ 截图,并断言界面状态与后端状态一致。
CDP 走 WebSocket,本机没有 websocket-client,因此这里复用 core/ws.py 的帧实现。
用法:
./.venv/bin/python tests/ui_acceptance.py # 默认连 127.0.0.1:8787
./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8787/ --out /tmp/ui
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import socket
import struct
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from urllib.parse import urlparse # noqa: E402
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
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}")
class CDP:
"""最小可用的 Chrome DevTools Protocol 客户端。"""
def __init__(self, ws_url: str, timeout: float = 30.0) -> None:
parsed = urlparse(ws_url)
self.sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 9222), timeout=timeout)
self.sock.settimeout(timeout)
key = base64.b64encode(os.urandom(16)).decode()
path = parsed.path + (("?" + parsed.query) if parsed.query else "")
request = (
f"GET {path} HTTP/1.1\r\nHost: {parsed.hostname}:{parsed.port}\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
self.buf = b""
header = b""
while b"\r\n\r\n" not in header:
chunk = self.sock.recv(4096)
if not chunk:
raise RuntimeError("CDP 握手失败")
header += chunk
head, _, rest = header.partition(b"\r\n\r\n")
if b"101" not in head.split(b"\r\n")[0]:
raise RuntimeError(f"CDP 未升级:{head[:200]!r}")
self.buf = rest
self.counter = 0
self.timeout = timeout
def _recv(self, n: int) -> bytes:
while len(self.buf) < n:
chunk = self.sock.recv(65536)
if not chunk:
raise RuntimeError("CDP 连接关闭")
self.buf += chunk
out, self.buf = self.buf[:n], self.buf[n:]
return out
def _read_frame(self) -> str:
head = self._recv(2)
opcode = head[0] & 0x0F
length = head[1] & 0x7F
if length == 126:
length = struct.unpack("!H", self._recv(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._recv(8))[0]
payload = self._recv(length) if length else b""
if opcode == 0x8:
raise RuntimeError("CDP 服务端关闭连接")
if opcode == 0x9: # ping → pong(带掩码)
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
self.sock.sendall(bytes([0x8A, 0x80 | len(payload)]) + mask + masked)
return self._read_frame()
return payload.decode("utf-8", "replace")
def _send(self, obj: dict) -> None:
payload = json.dumps(obj).encode()
mask = os.urandom(4)
header = bytearray([0x81])
length = len(payload)
if length < 126:
header.append(0x80 | length)
elif length < 65536:
header.append(0x80 | 126)
header.extend(struct.pack("!H", length))
else:
header.append(0x80 | 127)
header.extend(struct.pack("!Q", length))
header.extend(mask)
self.sock.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def call(self, method: str, params: dict | None = None) -> dict:
self.counter += 1
message_id = self.counter
self._send({"id": message_id, "method": method, "params": params or {}})
while True:
data = json.loads(self._read_frame())
if data.get("id") == message_id:
if "error" in data:
raise RuntimeError(f"CDP {method} 失败:{data['error']}")
return data.get("result") or {}
# 其它事件(Runtime.consoleAPICalled 等)先记录到 self.events
self.events.append(data)
events: list = []
def eval(self, expression: str, await_promise: bool = False):
result = self.call("Runtime.evaluate", {
"expression": expression,
"returnByValue": True,
"awaitPromise": await_promise,
})
if result.get("exceptionDetails"):
detail = result["exceptionDetails"]
raise RuntimeError(f"页面 JS 异常:{detail.get('text')} "
f"{(detail.get('exception') or {}).get('description', '')[:300]}")
return (result.get("result") or {}).get("value")
def click(self, x: float, y: float, button: str = "left", clicks: int = 1) -> None:
for event_type in ("mousePressed", "mouseReleased"):
self.call("Input.dispatchMouseEvent", {
"type": event_type, "x": x, "y": y, "button": button,
"clickCount": clicks, "buttons": 1 if event_type == "mousePressed" else 0,
})
def move(self, x: float, y: float) -> None:
self.call("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y, "buttons": 0})
def shot(self, path) -> None:
data = self.call("Page.captureScreenshot", {"format": "png"})
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(base64.b64decode(data["data"]))
def close(self) -> None:
try:
self.sock.close()
except OSError:
pass
def launch_chrome(width: int, height: int, port: int) -> subprocess.Popen:
chrome = next((p for p in CHROME_CANDIDATES if os.path.exists(p)), None)
if not chrome:
raise SystemExit("未找到 Chrome / Edge / Chromium")
profile = Path("/tmp/iosloc-ui-acceptance-profile")
subprocess.run(["rm", "-rf", str(profile)], check=False)
profile.mkdir(parents=True, exist_ok=True)
process = subprocess.Popen(
[
chrome, "--headless=new", "--disable-gpu", "--no-first-run",
"--no-default-browser-check", "--hide-scrollbars", "--disable-extensions",
f"--remote-debugging-port={port}", f"--user-data-dir={profile}",
f"--window-size={width},{height}", "about:blank",
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
)
deadline = time.time() + 25
while time.time() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as response:
pages = json.loads(response.read())
for page in pages:
if page.get("type") == "page":
return process, page["webSocketDebuggerUrl"]
except Exception:
time.sleep(0.4)
process.terminate()
raise SystemExit("无法连接 Chrome 调试端口")
def main() -> int:
global PASS, FAIL
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8787/")
parser.add_argument("--out", default="/tmp/ui-acceptance")
parser.add_argument("--port", type=int, default=9444)
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=900)
parser.add_argument("--keep-open", action="store_true", help="结束后不关闭 Chrome")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
print("\033[1m图形界面验收(真实 Chrome 驱动)\033[0m")
process, ws_url = launch_chrome(args.width, args.height, args.port)
cdp = None
try:
cdp = CDP(ws_url)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
cdp.call("Emulation.setDeviceMetricsOverride",
{"width": args.width, "height": args.height, "deviceScaleFactor": 2, "mobile": False})
cdp.call("Page.navigate", {"url": args.url})
time.sleep(3.5)
# ---- 0. 页面与服务端连通 ----
title = cdp.eval("document.title")
check("页面已加载", bool(title), str(title))
ws_state = cdp.eval("(function(){var d=document.querySelector('#connPill');return d?d.textContent.trim():''})()")
check("界面显示已连接服务端", "已连接" in str(ws_state), f"connPill={ws_state!r}")
device_text = cdp.eval("(function(){var d=document.querySelector('#deviceCard');return d?d.innerText.replace(/\\n/g,' | '):''})()")
check("设备卡片显示设备信息", "iPhone" in str(device_text) or "模拟" in str(device_text), str(device_text)[:120])
# ---- 1. 地图渲染 ----
map_info = cdp.eval(
"(function(){var c=document.querySelector('#map canvas');"
"var t=document.querySelectorAll('#map img').length;"
"return JSON.stringify({canvas:!!c,imgs:t,w:c?c.width:0,h:c?c.height:0})})()"
)
info = json.loads(map_info) if map_info else {}
check("地图画布已渲染", bool(info.get("w")) and bool(info.get("h")), str(info))
# ---- 2. 添加途经点:点工具条 + 在地图上点三个位置 ----
clicked_tool = cdp.eval(
"(function(){var b=document.querySelector('#toolAdd');if(!b)return 'no-btn';b.click();return b.className})()"
)
check("「添加途经点」按钮可点击", clicked_tool != "no-btn", str(clicked_tool))
time.sleep(0.4)
box = cdp.eval(
"(function(){var m=document.querySelector('#map');var r=m.getBoundingClientRect();"
"return JSON.stringify({x:r.x,y:r.y,w:r.width,h:r.height})})()"
)
rect = json.loads(box)
points = [
(rect["x"] + rect["w"] * 0.30, rect["y"] + rect["h"] * 0.35),
(rect["x"] + rect["w"] * 0.52, rect["y"] + rect["h"] * 0.55),
(rect["x"] + rect["w"] * 0.72, rect["y"] + rect["h"] * 0.42),
]
for (px, py) in points:
cdp.click(px, py)
time.sleep(0.45)
time.sleep(0.6)
wp_count = cdp.eval(
"(function(){var l=document.querySelectorAll('#wpList .wp-item, #wpList li, #wpList [data-index]');"
"return l.length})()"
)
check("地图点击添加了 3 个途经点", int(wp_count or 0) >= 3, f"列表条目={wp_count}")
cdp.shot(out_dir / "01-waypoints.png")
# ---- 3. 加载 OSRM 路线 ----
cdp.eval("(function(){var b=document.querySelector('#btnBuildRoute');if(b)b.click();return !!b})()")
route_ok = False
for _ in range(40):
time.sleep(0.5)
meta = cdp.eval("(function(){var d=document.querySelector('#statDistance');return d?d.textContent.trim():''})()")
if meta and meta not in ("—", "-", "0", ""):
route_ok = True
break
distance_text = cdp.eval("(function(){var d=document.querySelector('#statDistance');return d?d.textContent.trim():''})()")
check("「加载 OSRM 路线」生成了路线", route_ok, f"总距离={distance_text!r}")
engine_text = cdp.eval("(function(){var d=document.querySelector('#statEngine');return d?d.textContent.trim():''})()")
check("路线引擎已显示", bool(engine_text) and engine_text != "—", f"引擎={engine_text!r}")
cdp.shot(out_dir / "02-route.png")
# ---- 4. 播放(按钮是异步生效的:点击后等状态真正变为"播放中",必要时重试一次) ----
def click_play() -> None:
cdp.eval("(function(){var b=document.querySelector('#btnPlay, [data-action=play]');"
"if(b && !b.disabled)b.click();return !!b})()")
def wait_playing(timeout: float = 12.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
state = str(cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()"))
if "播放中" in state or "已暂停" in state:
return True
time.sleep(0.3)
return False
click_play()
playing = wait_playing(12.0)
if not playing:
time.sleep(1.0)
click_play()
playing = wait_playing(12.0)
check("点击播放后进入播放态", playing,
str(cdp.eval("document.querySelector('#chipPlayState').textContent")))
progressed = False
progress_text = ""
coord_text = ""
for _ in range(40):
time.sleep(0.5)
progress_text = str(cdp.eval("(function(){var d=document.querySelector('#chipProgress');return d?d.textContent.trim():''})()"))
coord_text = str(cdp.eval("(function(){var d=document.querySelector('#chipCoord');return d?d.textContent.trim():''})()"))
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("播放推进(地图 chips 显示进度)", progressed, f"chipProgress={progress_text!r}")
check("实时坐标已显示", any(ch.isdigit() for ch in coord_text), f"chipCoord={coord_text!r}")
play_state = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("播放状态文案正常", bool(play_state) and "未播放" not in str(play_state), f"chipPlayState={play_state!r}")
cdp.shot(out_dir / "03-playing.png")
# ---- 5. 暂停 / 继续 / 停止(暂停是异步推送,采样窗口内看是否出现过"已暂停") ----
cdp.eval("(function(){var b=document.querySelector('#btnPause, [data-action=pause]');if(b)b.click();return !!b})()")
paused_seen = False
paused_sample = ""
for _ in range(20):
time.sleep(0.15)
paused_sample = str(cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()"))
if "暂停" in paused_sample:
paused_seen = True
break
check("暂停后状态文案变化", paused_seen, f"{paused_sample!r}")
cdp.eval("(function(){var b=document.querySelector('#btnResume, [data-action=resume]');if(b)b.click();return !!b})()")
time.sleep(1.0)
resumed_text = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("继续后恢复播放", "暂停" not in str(resumed_text) or "播放" in str(resumed_text), f"{resumed_text!r}")
cdp.eval("(function(){var b=document.querySelector('#btnStop, [data-action=stop]');if(b)b.click();return !!b})()")
time.sleep(1.5)
stop_text = cdp.eval("(function(){var d=document.querySelector('#chipPlayState');return d?d.textContent.trim():''})()")
check("停止后状态回到未播放", "未播放" in str(stop_text) or "已停止" in str(stop_text), f"{stop_text!r}")
cdp.shot(out_dir / "04-stopped.png")
# ---- 6. 底图切换 ----
options = cdp.eval(
"(function(){var s=document.querySelector('#tileSourceSelect');"
"return JSON.stringify(Array.from(s.options).map(function(o){return o.value}))})()"
)
opts = json.loads(options or "[]")
check("底图下拉有多个瓦片源", len(opts) >= 3, str(opts))
if len(opts) >= 2:
cdp.eval(f"(function(){{var s=document.querySelector('#tileSourceSelect');"
f"s.value={json.dumps(opts[1])};s.dispatchEvent(new Event('change',{{bubbles:true}}));return s.value}})()")
time.sleep(3.0)
current = cdp.eval("(function(){var s=document.querySelector('#tileSourceSelect');return s.value})()")
check("切换底图生效", current == opts[1], f"{current!r} vs {opts[1]!r}")
cdp.shot(out_dir / "05-tilesource.png")
# ---- 7. 日志面板(先展开再看内容) ----
cdp.eval("(function(){var b=document.querySelector('#btnToggleLog');if(b)b.click();return !!b})()")
time.sleep(1.2)
log_chars = cdp.eval("(function(){var p=document.querySelector('#logBody');return p?(p.innerText||'').length:0})()")
check("日志面板能展开且有内容", int(log_chars or 0) > 30, f"字符数={log_chars}")
log_text = cdp.eval("(function(){var p=document.querySelector('#logBody');return p?(p.innerText||'').slice(0,200):''})()")
check("日志内容为中文可读", bool(log_text) and any("\u4e00" <= ch <= "\u9fff" for ch in str(log_text)),
str(log_text)[:100])
cdp.shot(out_dir / "06-logs.png")
# ---- 8. 收集页面控制台报错 ----
errors = []
for event in cdp.events:
method = event.get("method")
if method == "Runtime.exceptionThrown":
detail = event["params"]["exceptionDetails"]
errors.append(f"{detail.get('text')} {(detail.get('exception') or {}).get('description', '')[:200]}")
elif method == "Runtime.consoleAPICalled" and event["params"].get("type") == "error":
parts = [str(a.get("value", a.get("description", "")))[:160] for a in event["params"].get("args", [])]
errors.append("console.error: " + " ".join(parts))
check("页面无 JS 报错", not errors, " | ".join(errors[:4]))
if errors:
print("\n 页面报错明细:")
for item in errors[:10]:
print(" -", item[:240])
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:
if cdp:
cdp.close()
if not args.keep_open:
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}")
if FAILURES:
print("失败明细:")
for item in FAILURES:
print(" -", item[:220])
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())