我繼承過的每一份行動裝置錄製測試,最後都以相同的方式失效:有人移動了一個按鈕。
錄製指令寫著「點擊 (340, 712)」。重新設計把那個按鈕往上移了一列,測試依舊繼續點擊——如今點在空白處,或是其他剛好落在那裡的東西。它沒有馬上失敗。三個 Sprint 之後,它開始以令人困惑的方式失效,此時已經沒有人再信任這套測試了。
解決方法不是更好的錄製器,而是錄製不同的東西:不是你點擊的「位置」,而是你點擊的「物件」。這需要一份元素樹,而有一段時間我們並沒有。
tapflow 是一款開源、自託管的工具,能將 iOS 模擬器與 Android 模擬器串流到瀏覽器,讓整個團隊不必安裝任何東西就能測試建置版本。在此之前,它傳送的只有像素與點擊。這篇文章討論如何在沒有視窗的模擬器中取得元素樹,涵蓋兩個平台。我們拿這棵樹來做的事——重播在重新設計後依然存活的流程——是本系列的下一篇。
這條自動化軸線(流程執行器與 MCP 伺服器)目前仍屬實驗性。手動瀏覽器 QA 路徑才是成熟方案。
限制條件:沒有 WebDriverAgent,也沒有模擬器視窗
tapflow 早已能在不使用 WebDriverAgent 的情況下對 iOS 模擬器注入觸控——它載入 CoreSimulator.framework,透過 SimDeviceLegacyHIDClient 推送 HID 事件(那段故事在第 1 集)。串流則直接讀取 framebuffer IOSurface。這兩條路徑都不需要把 Simulator.app 顯示在畫面上,而且這是刻意設計:一台放在壁櫥裡的代理 Mac 執行四個模擬器時,不應該還要盯著四個視窗。
因此,我們用來取得元素樹的方案必須遵循同樣的規則。不能安裝 WDA,也不用與 Xcode 同步。不需要把模擬器視窗顯示在螢幕上。
我們第一次嘗試就遇到了這個限制。macOS 提供無障礙 API (AXUIElement),而 Simulator.app 透過它發佈內容。我們為此寫了一個輔助工具,在開發者的筆電上運作完美。但在無頭模式下,它什麼都回傳不了,因為 AX 橋接只有在模擬器視窗正在渲染時才存在。目前的 Xcode 用 simctl boot 啟動的模擬器根本不會開啟視窗。
所以我們最後得到一個只有在我們根本不需要它的情境下才有效的樹狀讀取器。
常駐在模擬器內部的 XCUITest 執行器
XCUITest 能透過 bundle id 讀取任何 App 的元素樹——這正是它的設計初衷——而且它在模擬器內部執行,因此不需要視窗。問題在於 xcodebuild test 原本設計是「執行測試、列印結果、結束」,而我們需要的是能連續數小時回答查詢的東西。
所以這個測試目標其實什麼都不測試。它啟動一個 HTTP 伺服器,然後阻斷:
// TreeRunner — 永不結束的 UI 測試
func testServeTree() {
let server = try! TreeServer(port: port)
server.start()
// 永久阻斷:程序保持常駐並持續提供服務
RunLoop.current.run()
}
進入全螢幕模式 退出全螢幕模式
伺服器大約 100 行 Network.framework 程式碼,包含兩條路由:GET /health 用於就緒檢查,以及 GET /tree?bundleId=<id>,它會回傳 XCUIApplication(bundleIdentifier:).debugDescription——包含畫面上所有元素的角色、標籤、識別碼與框架的完整元素子樹。這不是一份有文件記載的 API 合約,我會在限制章節再回來討論。
網路繫結是我最低估的細節。iOS 模擬器共用主機的網路堆疊,因此預設的 NWListener 繫結會監聽所有介面。這會把被測 App 的每一個畫面暴露在區域網路中,且沒有任何驗證,而這正是自託管設定本應避免的。requiredInterfaceType 還不夠。你必須固定本地端點:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"), port: nwPort
)
進入全螢幕模式 退出全螢幕模式
繫結失敗也會立即結束程序,因為安靜地處於 .failed 狀態的監聽器對父程序來說看起來像是活著的,會把過時的連接埠衝突變成 90 秒就緒輪詢後兩分鐘的神祕問題。
在 Node 端,XCUITreeReader 先用 build-for-testing 建立執行器一次(快取,因此只有第一次查詢會付出成本),接著用 test-without-building 以分離模式啟動,輪詢 /health,然後從常駐程序提供查詢。它是延遲啟動的,第一次樹狀查詢才會啟動,因此手動 QA——主要路徑,設計師只想檢查建置版本——永遠不會為它不會使用的執行器付出啟動成本。
關閉時會先終結分離的程序群組,然後對模擬器內的主機 App 執行 simctl terminate,因為該程序不在群組內,否則會繼續佔用連接埠:
// 分離產生 → 子程序領導自己的群組;終結整個群組
if (pid) process.kill(-pid, 'SIGTERM')
execFile('xcrun', ['simctl', 'terminate', udid, RUNNER_HOST_BUNDLE], () => {})
進入全螢幕模式 退出全螢幕模式
有一條規則比其他任何事情都重要:樹狀讀取永遠不會回傳空陣列來表示「出了問題」。垃圾回應主體、錯誤的 bundle id,或是 App 不再處於前景,都會產生明確的錯誤。「畫面沒有元素」應該只代表這個意思。如果它同時也代表基礎設施失敗,那麼建立在它之上的每一個測試都會變得模稜兩可。
Android 是簡單的那一個
uiautomator dump 已經能提供 XML 層級結構,而剖析器也很直觀。唯一需要留意的是 uiautomator 會等待視窗閒置後才進行傾印,因此含有持續動畫的 App——例如 spinner 或循環啟動畫面——永遠不會進入該狀態,傾印就會卡住。
逾時必須在裝置上執行,而不是在主機上:
adb exec-out timeout 10 uiautomator dump /dev/tty
進入全螢幕模式 退出全螢幕模式
這個 timeout 來自 Android 的 toybox。同樣的規則也適用於 iOS:基礎設施失敗絕不應該看起來像是有效的 UI 輸出。
單一結構,以及為什麼框架正規化很重要
兩個後端都會剖析成相同的元素形狀:
interface UIElement {
role: 'button' | 'text' | 'input' | 'cell' | 'image' | ...
label: string
identifier: string
frame: { x: number; y: number; width: number; height: number } // 0–1
enabled: boolean
}
進入全螢幕模式 退出全螢幕模式
role 將兩種詞彙正規化為一種:iOS 的 XCUIElementType 與 Android 的類別名稱(AppCompatButton、AutoCompleteTextView、RecyclerView),排序方式讓複合名稱優先於一般子字串——ToggleButton 必須先比對到 switch,才會比對到 Button。
最大的簡化來自把每一個框架正規化到與觸控路徑相同的 0–1 座標空間。iOS 的 debugDescription 框架以點為單位,會除以視窗框架。Android 的 bounds 除以根節點(已涵蓋整個顯示區域),因此橫向模式不需要額外的 wm size 查詢就能運作。
之後,整個管線變成:
tree query → element.frame center → tap(x, y) → 與人類點擊相同的 HID 路徑
進入全螢幕模式 退出全螢幕模式
不需要第二條連線到裝置,也不需要獨立的驅動程式,更不需要額外的座標轉換。
這能做到什麼
過去,一個測試只有一種方式描述要點擊哪裡:
(340, 712)
進入全螢幕模式 退出全螢幕模式
現在它可以直接說:
tapOn: "Sign in"
進入全螢幕模式 退出全螢幕模式
這會對照樹狀結構解析——先比對精確識別碼,再比對精確標籤,最後是部分標籤——然後把匹配元素的框架中心送進上面展示的同一條點擊路徑。
這棵樹也不只用於流程執行器:
GET /api/v1/sessions/:sessionId/ui-tree # REST
query_ui_tree # MCP tool
進入全螢幕模式 退出全螢幕模式
驅動工作階段的 LLM 代理讀取的結構與流程完全相同。第 4 集透過截圖給代理一雙眼睛。這次則給它正在查看的東西的名稱。
值得知道的限制
-
iOS 的樹狀結構來自
debugDescription。 這是一種文字格式,而不是穩定的 API。剖析器是一個帶有測試夾具的純函式,因此 Xcode 格式變更會先在單元測試中失敗——但格式仍有可能改變。 -
iOS 上的
enabled是近似值。debugDescription沒有暴露它,因此元素預設為enabled: true。 -
一次只能有一個常駐執行器。 它監聽固定連接埠(
xcodebuild不會將主機環境變數傳入模擬器內的執行器,因此無法使用每裝置連接埠)。並行多裝置的樹狀查詢會被延遲。
立即試用
npm install -g tapflow
tapflow start # → http://localhost:4000
進入全螢幕模式 退出全螢幕模式
- 儲存庫:https://github.com/jo-duchan/tapflow
- 文件:https://www.tapflow.dev
- 本系列先前文章:我們為什麼打造它,以及觸控路徑如何運作、降低串流延遲。
本系列下一篇:當測試能為它所點擊的物件命名後,你能建構什麼,以及我們讓執行器拒絕猜測的四個地方。
如果你用其他方式解決了無頭樹狀結構的問題——特別是比 debugDescription 更穩定且不需要 WebDriverAgent 的方案——我很樂意聽聽你的做法。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.