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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
420 lines
19 KiB
Python
420 lines
19 KiB
Python
#!/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())
|