私がこれまで引き継いだモバイルテストは、すべて同じ理由で死んでいった。誰かがボタンを動かしたからだ。

録画された内容は「(340, 712)でタップ」と指示していた。リデザインでそのボタンが1行上に移動した結果、テストは空の領域や、そこに偶然あった別のものをタップし続けていた。すぐには失敗しなかった。3スプリント後に不可解な失敗が始まり、その頃には誰もテストスイートを信用しなくなっていた。

必要なのは、より優れたレコーダーではない。記録する対象を変えることだ。つまり「どこをタップしたか」ではなく「をタップしたか」を記録する。そのためには要素ツリーが必要で、しばらくの間、私たちにはそれがなかった。

tapflowはオープンソースのセルフホスト型ツールで、iOSシミュレータとAndroidエミュレータの画面をブラウザにストリーミングし、チーム全体が何もインストールせずにビルドをテストできるようにする。これまでは、ピクセルを一方に、タップをもう一方に送るだけだった。この記事では、ウィンドウを持たないシミュレータから両プラットフォームで要素ツリーを取得する方法について述べる。このツリーを使ってリデザインに耐えるフローを再生する方法は、次回の記事で取り上げる。

この仕組みが支える自動化軸——フローランナーとMCPサーバー——は実験的なものである。成熟しているのは、手動のブラウザQAパスである。


制約:WebDriverAgentは使えず、シミュレータウィンドウもない

tapflowはすでにWebDriverAgentなしでiOSシミュレータにタッチを注入している——CoreSimulator.frameworkをロードし、SimDeviceLegacyHIDClientを通じてHIDイベントを送る(その経緯はep.1)。ストリーミングはフレームバッファのIOSurfaceを直接読み取る。どちらの経路もSimulator.appを画面に表示する必要がなく、これは意図的だ。クローゼットに置かれたエージェントMacが4台のシミュレータを動かすのに、4つのウィンドウを世話する必要はない。

したがって、ツリー取得にも同じルールが適用されなければならない。インストールやXcodeとの同期が必要なWDAは使えない。画面にシミュレータウィンドウを表示することもできない。

最初の試みは、まさにこの制約にぶつかった。macOSはアクセシビリティAPI(AXUIElement)を公開しており、Simulator.appはその内容を公開している。私たちはそれ用のヘルパーを作成し、開発者のノートPCでは完璧に動作した。しかしヘッドレス環境では何も返さなかった。AXブリッジはシミュレータウィンドウが実際に描画されている間しか存在しないからだ。現在のXcodeでsimctl bootで起動したシミュレータは、ウィンドウを一切開かない。

結果、私たちは「必要としない状況でしか動作しない」ツリー取得機構を手に入れることになった。


シミュレータ内部に常駐するXCUITestランナー

XCUITestはバンドルIDで任意のアプリのツリーを読み取れる——それが本来の設計だ——そしてそれはシミュレータ内部で実行されるため、ウィンドウは不要である。問題は、xcodebuild testが「テストを実行し、結果を出力して終了する」ことを前提としている点だ。私たちが必要なのは、数時間にわたってクエリに応答し続けるものだ。

そこで、テストターゲットは実際には何もテストしない。HTTPサーバーを起動し、ブロックする:

// TreeRunner — 決して終了しないUIテスト
func testServeTree() {
    let server = try! TreeServer(port: port)
    server.start()
    // 永遠にブロック:プロセスは常駐し、応答を続けられる
    RunLoop.current.run()
}

Enter fullscreen mode Exit fullscreen mode

サーバーはNetwork.frameworkで約100行で、2つのルートを持つ:GET /health(準備完了確認用)とGET /tree?bundleId=<id>(画面上のすべての要素について役割、ラベル、識別子、フレームを含む完全な要素サブツリーをテキストで返す)。これはドキュメント化されたAPI契約ではなく、制限セクションで後述する。

ネットワークバインディングは最も過小評価していた部分だった。iOSシミュレータはホストのネットワークスタックを共有するため、デフォルトのNWListenerバインドはすべてのインターフェースでリッスンする。これでは認証なしで被テストアプリの画面をローカルネットワークに公開することになり、セルフホスト型セットアップの目的に反する。requiredInterfaceTypeでは不十分だ。ローカルエンドポイントを明示的に指定する必要がある:

params.requiredLocalEndpoint = NWEndpoint.hostPort(
    host: NWEndpoint.Host("127.0.0.1"), port: nwPort
)

Enter fullscreen mode Exit fullscreen mode

バインドに失敗するとプロセスは即座に終了する。なぜなら、.failed状態で静かに待機しているリスナーは親プロセスから見て生きているように見え、90秒の準備完了ポーリング後に2分間の「ポート競合の謎」を生むからだ。

Node側では、XCUITreeReaderbuild-for-testingでランナーを一度ビルドし(キャッシュされるため、最初のクエリのみコストがかかる)、test-without-buildingでデタッチ起動し、/healthをポーリングした後、常駐プロセスからクエリに応答する。初回ツリークエリ時に遅延起動するため、手動QA(デザイナーがビルドを確認するだけの主要な用途)では、使用しないランナーの起動コストを支払わずに済む。

シャットダウン時には、デタッチプロセスグループをkillした後、シミュレータ内のホストアプリに対してもsimctl terminateを実行する。ホストアプリはグループに属さないため、ポートを保持し続けてしまうからだ:

// デタッチ起動 → 子プロセスは独自のグループを率いる。グループ全体をkillする
if (pid) process.kill(-pid, 'SIGTERM')
execFile('xcrun', ['simctl', 'terminate', udid, RUNNER_HOST_BUNDLE], () => {})

Enter fullscreen mode Exit fullscreen mode

最も重要なルール:ツリー読み取りは「何か問題が発生した」ことを意味する空配列を決して返さない。ガベージなレスポンスボディ、誤ったバンドルID、またはフォアグラウンドにないアプリはすべて明示的なエラーを返す。「画面に要素がない」ことは、まさにそのことを意味するだけでなければならない。インフラ障害も同じ意味を持ち得るなら、それに基づくすべてのテストが曖昧になる。


Androidは簡単だった

uiautomator dumpはすでにXML階層を返し、パーサーは単純だ。注意すべき点は、uiautomatorがウィンドウがアイドル状態になるのを待ってからダンプするため、連続アニメーション(スピナーやループするスプラッシュ画面)を持つアプリはアイドル状態にならず、ダンプがハングする点だ。

タイムアウトはホストではなくデバイス上で実行する必要がある:

adb exec-out timeout 10 uiautomator dump /dev/tty

Enter fullscreen mode Exit fullscreen mode

この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
}

Enter fullscreen mode Exit fullscreen mode

roleは2つの語彙を1つに正規化する:iOSのXCUIElementTypeとAndroidのクラス名(AppCompatButtonAutoCompleteTextViewRecyclerView)で、複合名が一般的な部分文字列より先に解決されるよう順序付けられている——ToggleButtonButtonより先にswitchにマッチしなければならない。

最大の簡素化は、すべてのフレームをタッチパスがすでに使用している0–1座標空間に正規化することから来る。iOSのdebugDescriptionフレームはポイント単位で到着し、ウィンドウフレームで割られる。Androidのboundsはすでにディスプレイ全体をカバーするルートノードで割られるため、ランドスケープモードでも追加のwm sizeクエリが不要になる。

その後、パイプライン全体は次のようになる:

tree query → element.frame center → tap(x, y) → 人間のタップと同じHIDパス

Enter fullscreen mode Exit fullscreen mode

デバイスへの2つ目の接続、別個のドライバ、余分な座標変換は一切不要だ。


これが実現すること

従来、テストがタップ位置を記述する方法は1つしかなかった:

(340, 712)

Enter fullscreen mode Exit fullscreen mode

今では次のように書ける:

tapOn: "Sign in"

Enter fullscreen mode Exit fullscreen mode

これはツリーに対して解決される——まず完全一致の識別子、次に完全一致のラベル、最後に部分一致のラベル——そしてマッチした要素のフレーム中心が前述のタップパスを通る。

ツリーはフローランナーだけでなく、以下にも利用できる:

GET /api/v1/sessions/:sessionId/ui-tree     # REST
query_ui_tree                               # MCP tool

Enter fullscreen mode Exit fullscreen mode

セッションを駆動するLLMエージェントは、フローと同じスキーマを読み取る。ep.4はスクリーンショットを通じてエージェントに「目」を与えた。今回は、見ているものに名前を与える。


知っておくべき制限

  • iOSツリーはdebugDescriptionから得られる。これはテキスト形式であり、安定したAPIではない。パーサーはフィクスチャテスト付きの純粋関数なので、Xcodeのフォーマット変更は本番前にユニットテストで検出される——ただし、フォーマット自体は変わり得る。
  • iOSでのenabledは近似値である。debugDescriptionはこれを公開しないため、要素はデフォルトでenabled: trueとなる。
  • 同時に1つの常駐ランナーのみ。固定ポートでリッスンする(xcodebuildはホストの環境変数をシミュレータ内ランナーに伝播しないため、デバイスごとのポートは利用できない)。複数デバイスでの同時ツリークエリは延期される。

試してみる

npm install -g tapflow
tapflow start          # → http://localhost:4000

Enter fullscreen mode Exit fullscreen mode

次回の記事:テストがタップ対象に名前を付けられるようになったときに構築できるもの、そしてランナーが「推測」を拒否する4箇所について。

もしdebugDescriptionより安定していてWebDriverAgentを必要としない、ヘッドレスツリー問題の別の解決策をお持ちでしたら、ぜひお聞かせください。