コンテンツにスキップ

English · 日本語

ドライバ抽象、バックエンド、環境管理

ひとつの Driver インターフェースの裏にバックエンドxcuitest(iOS Simulator)、adb(Android エミュレータ)、playwright(web ブラウザ)、それにテスト用のインメモリ fake)を置き、能力差を 抽象側で吸収します。プラットフォーム対応のレジストリが backend リストから actuator を選びます。 iOS ではアプリの起動(boot/launch)を simctl ラッパが担い、Android ではその双子である adb ラッパが担います。

実装: bajutsu/drivers/base.py / xcuitest.py / adb.py / playwright.py / fake.py)・ bajutsu/backends.pybajutsu/simctl.pybajutsu/adb.py

関連: selectors(解決) · concepts の安定度順ラダー · run-loop


Driver Protocol

すべてのバックエンドが満たす共通インターフェースです(base.pyruntime_checkableProtocol)。 操作(tap/type/swipe/wait/query)は actuator のみが行います

class Driver(Protocol):
    def query(self) -> list[Element]: ...           # 画面の要素ツリー
    def tap(self, sel: Selector) -> None: ...
    def tap_point(self, p: Point) -> None: ...       # 生座標 tap(システムアラート等)
    def long_press(self, sel: Selector, duration: float) -> None: ...
    def swipe(self, frm: Point, to: Point) -> None: ...    # 素のポインタドラッグ(座標形)
    def scroll(self, frm: Point, to: Point) -> None: ...   # 慣性なしの方向指定スクロール(BE-0227, BE-0326)
    def type_text(self, text: str) -> None: ...
    def wait_for(self, sel: Selector) -> bool: ...   # 単発チェック:現在の画面に一致するか
    def screenshot(self, path: str) -> None: ...
    def capabilities(self) -> set[str]: ...          # 提供能力(actuator / フォールバック解決用)

wait_for について: 契約上、単発チェックです(BE-0118)。現在の画面を一度だけ確認して返し、ループはしません。締め切りまでのポーリングは共有ヘルパ base.wait_until に集約してあり、呼び出し側が渡す timeout は、どの backend でも同じ実時間を意味します。各ドライバがそれぞれのループを持つことはありません。run ループ自身の条件待機は、orchestrator が query() を直接ポーリングして行います(_waitrun-loop)。したがって wait_until を使うのは、このループの外側の呼び出し側(たとえば golden_assert)だけです。

能力(Capability

capabilities() が返すトークン集合で、actuator 選択、証跡のフォールバック解決、プリフライト能力検査(後述)に使います。

能力 意味 xcuitest adb playwright fake
query 要素ツリー取得
elements 要素ダンプ証跡
screenshot スクリーンショット
semanticTap id/label で直接タップ(座標不要)
conditionWait ネイティブ条件待機
network ネイティブネットワーク監視
multiTouch 2 本指ジェスチャ(pinch / rotate)
textSelection フォーカス中フィールドの全選択とクリップボードへのコピー
selectOption ネイティブ <select> を値で設定(web のみ)
handleSystemAlert iOS SpringBoard の許可プロンプトのボタンをネイティブにタップ
pickerWheel ホイール型のピッカーを指定した行へ設定(iOS のみ)
handleTipkitTip 操作をブロックしている Apple TipKit の tip を閉じる(iOS のみ)
deviceControl.setLocation 疑似 GPS 位置の設定
deviceControl.clipboard クリップボードの読み取り / 書き込み / クリア
deviceControl.push プッシュ通知の配信
deviceControl.clearKeychain キーチェーンのクリア
deviceControl.appLifecycle アプリのバックグラウンド化 / フォアグラウンド化
deviceControl.statusBar ステータスバーの上書き / クリア

deviceControl.* トークンは、DeviceControl 一族を操作ごとに分割したものです(BE-0212、BE-0128 の粗い deviceControl から分割しました)。これにより、バックエンドは自分が忠実に実現できる操作だけを公開でき、部分的にしか実現しないバックエンドも残りを誤って公開せずに済みます。XCUITest は simctl を通じて一族全体を実現しますが、Android エミュレータは setLocationclipboard だけを実現します(その push / キーチェーン / ステータスバー / アプリライフサイクルの各操作には忠実な等価物がありません)。

adb はリーンな端に位置し、frame 中心の座標で操作します。semantic tap を持たないため、run ループは query() で要素を一意に確定しその中心をタップします。一方 XCUITest は豊かな端に位置し、identifier で直接 tap し、ネイティブの条件で待機し、pinch / rotate をネイティブに実行します。adb は query / elements / screenshotmultiTouch(rooted device での sendevent 2 本指スイープ、BE-0232)に加えて、エミュレータが実現できるデバイス制御のサブセット deviceControl.setLocationdeviceControl.clipboard を公開します(BE-0211)。デバイス制御一族の残りにはエミュレータでの忠実な等価物がないため、公開しません。fake ドライバはテストでそれらのコードパスを動かすためだけに、より広い能力集合(semanticTap / conditionWait / multiTouch)を公開します。playwright(web)ドライバは semanticTap / conditionWait(Playwright がネイティブに持つ)に加えて network(アプリ側の協力なしに通信を観測しスタブできる初めてのネイティブネットワーク対応バックエンド)と multiTouch(Chromium DevTools プロトコルの Input.dispatchTouchEvent で pinch / rotate を合成)を公開します(BE-0054)。

プリフライト能力検査(BE-0082)

バックエンドの能力集合は静的なので、選んだ actuator が持たない能力をシナリオが必要とするかどうかは、デバイス作業の前に分かります。run の開始時(actuator を選んだ後、最初のデバイスを lease する前)に、runner は各シナリオを actuator の能力と照合し(bajutsu/capability_preflight.py)、未対応のシナリオを即座に失敗させます。集約した 1 つの理由(UnsupportedAction 相当)を付けて、デバイスを起動して途中で失敗するのを避けます(prime directive #2:速く明確に失敗する)。検査は (シナリオ, 能力集合) の純粋関数で、デバイスも時計も使いません。シナリオ単位なので、未対応のシナリオだけが失敗し、残りは実行されます。

検査は、能力集合で明確に判定できる真の hard requirement だけを門にします。pinch / rotatemultiTouchselectOptionselectOption トークン(web だけで通る <select> の切り替え。iOS / Android はデバイス作業の前に拒否されます)、select / copytextSelection(全選択とクリップボードへのコピー。web コンテキストはこれらについて座標専用でどちらも拒否します。delete / clear はどのバックエンドも delete_text を実現できるので門にしません)、visual アサーションは screenshothandleSystemAlerthandleSystemAlert トークン(xcuitest だけが公開します)、setPickerValuepickerWheel トークン(こちらも xcuitest だけです。ホイール型のピッカーは iOS ネイティブのコントロールなので、Android と web はデバイス作業の前に拒否されます)、そして各デバイス制御ステップは自分の操作に対応するトークンを必要とします。setLocationdeviceControl.setLocation、クリップボードのステップは deviceControl.clipboardpushdeviceControl.push、という具合です(この操作ごとのトークンへの分割は BE-0212、前掲)。permissions の各エントリも同様に、サービスごと(deviceControl.permissions.<service>)の門を通すので、未対応のサービスはフィールド全体としてではなく1 つずつ名指しされます。すべての run は queryelements を必要とします。relaunch はここに含みません。relaunchDeviceControl ではなく、注入される relauncher で門を通すからです。一方、conditionWait は門にしません(run ループはすべての待機を polling で実装するので、どのバックエンドもこのトークンを必要としません)。network も門にしません(XCUITest は network を公開しませんが、アプリ側の collector で通信を捕捉するため、request / event / requestSequence / responseSchema アサーションや until: { request } 待機は iOS でも動きます)。gestures.py_require_multi_touch は、ジェスチャ実行時の多層防御の検査として残します。デバイス制御ステップについても同様に _need_control を残し、その run で DeviceControl がまったく配線されていない場合(デバイスを固定しない並行 run など)を捕捉します。トークンが操作ごとになったので、一族の一部だけを実現するバックエンド(Android エミュレータの setLocationclipboard)は、公開した操作については preflight を通し、残りについては速く失敗します。未対応のステップは1 つずつ名指しされ、一族が全か無かで扱われることはありません。

XCUITest(iOS)

BE-0290 で idb を撤去して以来、iOS の唯一の backend です。XCTest のオートメーションスナップショットを、実機上に常駐する runnerBajutsuKit)を loopback HTTP で駆動して読み取り、アプリ側の統合なしに任意のアプリを bundle id で駆動します。実装: drivers/xcuitest.py。frame 中心の座標に解決するのではなく、能力モデルの豊かな端に位置します(semantic tap、ネイティブ条件待機、multi-touch、テキスト選択)。Xcode の xcodebuild が必要です。

  • query(): XCTest のオートメーションスナップショットを読み取り、各要素を Element に写します。このスナップショットはグループコンテナの内側まで降りるので、座標系 backend のフラットな frame ダンプと違い、完全に展開された要素ツリーを描き出します(AXLabel/AXValue/アクセシビリティ識別子 を label/value/id に写します)。
  • query() は、提示された SFSafariViewController(サインインページや利用規約のためにアプリが開くアプリ内ブラウザ)も読み取ります。読み取り元は、それを描いているプロセス com.apple.SafariViewService です。BE-0316 が SpringBoard のプロンプトを読むのと同じやり方です(BE-0396)。アプリ自身のスナップショットは、このブラウザを iOS のバージョンによって違う形で報告します。iOS 18 までは部分木全体を写して返し、iOS 26 からはプロセス境界で止まり、その下を何も返しません。そこで写しの側を刈り取り、サービス自身のツリーを代わりに合流させます。こうしてどちらのバージョンでも欠けがなく、二重にも報告しない 1 つのツリーになります。閉じるコントロールは、バージョン間で識別子が食い違う唯一の chrome です(iOS 26 は Close を付け、iOS 18 は識別子を持たずラベル Done だけです)。runner はどちらでも iOS 26 の Close識別子として報告するので、シナリオは id: Close という 1 つのセレクタで指せます。正規化するのは識別子だけで、ラベルはプラットフォームが読み上げるまま(iOS 18 では Done)なので、label を使ったセレクタは両バージョンをまたげません。ブラウザの要素はframe の中心を座標でタップして操作します。XCUIElement.tap() はプロセス境界を越えてページ本体には届きますが、ブラウザ自身の chrome では黙って落とされるためです。iOS 18 にある無効状態の ForwardButton は iOS 26 に対応するコントロールがないので、シナリオはこれに依存できません。
  • tap(sel): _resolve で一意確定します(not-found はリトライ、ambiguity は即失敗: 実機ツリーは遷移中に一時的に空になり得るため)。確定後、要素をアクセシビリティ識別子で直接 tap します(座標を経由しない semantic tap。BE-0289 はスタックしたスナップショットハンドルを再解決し、依然として一意に一致するときだけ再操作します)。XCTest が拒んだ tap は、失敗する前にもう 1 段進みます。iOS は包んでいるコントロールより膨らんだ container を報告することがあるので、ドライバは対象の名前付きの子孫を調べ、到達できるものがちょうど 1 つならそこへタップして substitution: soleHittableDescendant を記録します。0 個または複数なら、どれかを選ばずに失敗し、候補を名指します(selectors)。
  • wait_for: runner のネイティブな条件待機を使います。
  • pinch / rotate: runner がネイティブに実行する 2 本指の multi-touch ジェスチャです。
  • select / copy: フォーカス中フィールドのネイティブなテキスト選択です。
  • screenshot: simctl io screenshot

汎用の runner は XCUIApplication(bundleIdentifier:) を使うので、アプリ側の協力なしにインストール済みの任意のアプリを駆動します。Simulator の実行は runner の config を一切必要としません。target が xcuitest.testRunnerxcuitest.build も指定しないときは、wheel にパッケージデータとして同梱された Simulator 用 runner に解決します(BE-0292)。明示的な testRunnerbuild は依然としてこの既定より優先し、deviceType: device は引き続き明示的な署名済み runner を必要とします。署名済み runner はオペレーターのチーム向けに Bajutsu が同梱できないためです。この backend は make -C demos/showcase run-swiftuiios-e2e.yml CI ワークフローで実機検証済みです(iPhone 17 Pro、最近の iOS)。XCUITest backend は pip extra を必要とせず、xcodebuild は Xcode が供給します。

adb(Android)

ヘッドレスで座標ベースの、唯一の座標系バックエンドです。semantic tap を持たないため、抽象側で id → frame 中心 → 座標 tap に解決します。実装: drivers/adb.pybajutsu/adb.py(ロードマップ BE-0007)。

  • query(): ウィンドウの UI Automator XML を読み取り、純粋なパーサ(parse_hierarchy)が各 <node>Element に写します。読み取りは、常駐 UI Automator サーバがビルド済み(make -C BajutsuAndroidUIAutomatorServer build)のときはそのサーバ経由で行います。温めた 1 つの UiAutomation セッションが adb forward 越しに GET /source へ答えるので、1 回の読み取りは約 0.1〜0.3 秒で済み、呼び出しのたびに約 2.4 秒かかる adb -s <serial> exec-out uiautomator dump /dev/tty を都度起動せずに済みます(ロードマップ BE-0245)。常駐サーバの全画面ダンプはアクティブウィンドウへ絞り込むので、ダンプ経路と同じ Element を返します。サーバが未ビルドのとき、またはチャネルに失敗したときは uiautomator dump にフォールバックし、BAJUTSU_ADB_RESIDENT01)でどちらの経路にも固定できます。
  • セレクタの写像: resource-ididentifier<package>:id/ 接頭辞を剥がしてローカル名にするので、testTagsAsResourceId で表出した Compose の testTag はそのまま、ネイティブの android:id は接頭辞が落ちます)、textlabelcontent-desc へフォールバック)、content-descvalue(アプリは状態値をここにミラーします。SPEC §2.1)、ウィジェットの class(と enabled / selected / checked の状態)→ traits です。
  • id 照合ポリシー: ローカル名は厳密一致で照合します。ドライバは ._ の書き換えをしません。書き換えは別々の id を取り違え、決定性を損なうためです。プラットフォーム本来の id 構文が SPEC の id をそのまま再現できない場合(Android Views の android:id.- も許さず、stable.refreshstable_refresh として現れます)は、シナリオが 1 つのセレクタに id を両方の形で持ち(id: [stable.refresh, stable_refresh])、候補の OR として照合します(BE-0221)。scenarios を参照してください。
  • tap(sel): _resolve で一意性を確定します(not-found はリトライ、ambiguity は即失敗。遷移中のダンプは一時的な null-root としてリトライし、2 件以上の一致は即座に失敗させます)。確定後、taplong_pressdouble_tap は、解決した要素の identity(host 側で計算した座標ではなく、生のアクセシビリティ属性値と ordinal の組み合わせ)を resident server の POST /act(ロードマップ BE-0339)へ送ります。server は自身が保持する live なツリーに対してその identity を再解決し、同じ warm session から inject するので、ジェスチャは inject する瞬間にデバイスが持つ bounds へ着地し、1 回前の round trip で host が計算した座標へは着地しません。stale 応答(host が数えた時点から identity の一致件数が変わった場合)は有界にリトライします。リトライを使い切ったとき、channel に /act エンドポイントがない(古い server の)とき、または channel 自体が fault したときは、host 側で計算した座標へフォールバックします(tap は frame 中心への adb shell input taplong_press は duration だけ保持する同一点の swipe)。server が inject したにもかかわらず、その応答が host へ届かなかったときは、2 回目の touch が 1 回目に重なって着地する危険を避けるため、driver はそのジェスチャを完了扱いにします。double_tap のデバイス側経路は、server 側の 1 回の呼び出しから両方の MotionEvent を生成し、round trip のたまたまの timing に間隔を委ねる代わりに、固定した interval を宣言します。その座標フォールバックは後述の実機アクチュエーションの忠実度を参照してください。swipe は実際のドラッグにするため有限の duration を付け、type_textinput text(空白はその %s エスケープで送ります)です。
  • 座標の解決は、pan にツリーが追いつくまで待ちます。 Android はまず内容を動かし、新しい frame を伝えるアクセシビリティ更新をその後に publish します。その 2 つの時点のあいだに当たった読み取りは、スクロール前の画面を返します。しかも読み取りを繰り返しても、すでに誤っている frame について互いに一致します。ツリーは揺れて見えるのではなく、誤ったまま自己整合しているのです。そのため、連続 2 回の読み取りの一致を settle と見なす判定だけでは、遅れを検出できません。そこで frame をまとめて動かすジェスチャ(swipescrollpinchrotate)の直後、さらにデバイス側か座標かを問わずあらゆる tap / long_press / double_tap の直後(BE-0332)に、ドライバはジェスチャ前の画面が持っていた frame の射影を記録します。次に座標を解決するときは、射影が記録から動き、さらに短時間そのまま保たれるまで読み直します。タップもレイアウトを動かしうる(メニューを開く、行を展開する、ステッパーを進める)ので、その次に続くアクチュエータは、タップよりにデバイスが公開したツリーに対して解決しなければなりません。タップ前のツリーではいけないのです。tap_point(生の座標)と back(解決する対象を持たない)は、後追いすべきレイアウト由来の対象がないので、この待ちを張りません。前述の POST /act 経路は、バリアが立てている問いに、その発生源で答えます。そのため、デバイスが確認したジェスチャにはバリアを張りません(BE-0339)。resident server は inject したあと、warm session がすでに観測しているアクセシビリティイベントの流れを短時間だけ待ち、inject より後に届いた最初のイベントのデバイス時刻を host へ返します。こうして確認が取れたジェスチャは、着地したと host が知らされる時点で、すでにツリーへ届いています。それはバリアが待つはずだったものそのものです。その待ち時間のうちに確認が取れなかったジェスチャは、座標での inject とまったく同じようにバリアを張ります。ここには、由来の異なる 3 つの場合が一緒に落ちてきます。frame を動かさなかったジェスチャ、待ち時間より遅い publish、そして待つ機能自体を持たない古い server です。確認はデバイスが与えるものであって、ドライバが仮定してよいものではありません。同一性で指すフォロワーと違い、座標で解決するフォロワー(pinchrotate、方向付き swipe/drag のアンカー)には、自己修復のための stale 再解決がないからです。バリアの待ち時間には実時間の上限を設け、使い切ったときはその旨を警告として残します。この上限は、scroll のループが内容の末尾を確認してから失敗するために使う値と同一です(ReadLagProvider、BE-0326 / BE-0332。architecture を参照)。publish の遅れは 1 つなので、予算も 1 つにして複数の経路で使います。方向付きの swipedrag だけは、解決の場所が異なります。この 2 つは、ステップが指定するアンカー要素から端点をドライバの外側で計算するため、ドライバはセレクタではなく 2 つの座標を受け取り、自分でツリーを settle できません。settle を必要とするバックエンドはそれを公開し(SettledReadProvider)、ハンドラは素の読み取りに代えてその読み取りを使います。このプロトコルを実装しないバックエンドは、1 回の読み取りのままです。この継ぎ目がないと、方向付き swipe を 2 回続けたとき、2 回目は 1 回目の pan 前の frame を基準にしてしまいます。

この判定が「違う」ではなく「追いついた」を意味するために、3 つの条件を置いています。短時間保たれることを要求するのは、追いつきが一括では起きないからです。Android は node の bounds を 1 つずつ publish し直すため、追いつきの途中に当たった読み取りは新しい frame と古い frame を混在させ、速い読み取りが 2 回とも同じ途中状態に当たって互いに一致することがあります。退化した読み取りは最初から数えません。空の射影はどの実在の射影とも違うので、数えると、読み取り経路自身がまだリトライしているツリーに予算を使ってしまいます。そして記録する射影は、前回の読み取り以降に何かが actuate していれば読み直します。その actuation より前の baseline は、無いよりも悪いからです。ジェスチャ後の最初の読み取りがその baseline から動いてしまい、ジェスチャが publish されたと数えられてしまいます。さらに、まだ publish を待っているジェスチャがあれば、次のジェスチャの baseline を取る前にそれを待ち切ります。この場合は読み直しでは救えません。読み取り自体がジェスチャ前の画面を返すので、先のジェスチャの publish を後から新しいジェスチャのものと取り違えてしまいます。

判定にはすべての読み取りが寄与します。待ちが自分で発行した読み取りだけではありません。ジェスチャと次の actuator のあいだにランナーがすでに取る読み取り(waitassert、ステップ後のキャプチャ)が通常はこの判定を満たすので、ツリーが追いついている実行は何も待ちません。

この待ちによって、継続的インテグレーションのエミュレータで gestures が間欠的に落ちる問題が解消します。その実行では、ツリーが 73px のスクロールを 1 秒以上伏せました。long_press は対象の下端より 10px 下を狙ってしまい、該当ステップのスクリーンショットが 1 ピクセルも違わないまま、mirror された値は idle に留まりました。 同じ publish の遅れは、シナリオ途中の extract にも及びます(BE-0332)。アクションがツリーに mirror する値は、アクションが返った後に一拍遅れて届くことがあり、アクション直後の最初の読み取りどうしはアクション前の値で互いに一致してしまいます。extract.yaml はカウンタの一つ前の値を束縛し、後続の assert が現在値と比べて正しい実行を落としました。この経路では、settle のポーリングは pan の「ステップ前の読み取りと違うか」という判定を使えません。extract の基準となる読み取りは、アクション直後の 1 回の読み取りにすぎず、それ自体が古くなりうるからです。そこで代わりに、一致した読み取りが予算のぶんだけアクションより後であることを要求します。したがって読み取り遅延の申告は 1 つの契約です。非ゼロの read_lag() を返すバックエンドは、内容を動かすアクチュエーション後のあらゆる座標解決と、シナリオ途中のあらゆる extract が、ツリーの追いつきを待って予算のぶんだけ費やしうることを引き受けます。この費用は、アクション前の画面にまだ一致している読み取りにのみ払われ、すでに反映済みの読み取りには払われません。遅延を申告しないバックエンドは、1 回読み取りで速く失敗する従来の挙動を保ちます。常駐リーダーは、ホストが比較できる読み取りマークを発行するようになり(BE-0332 作業単位 3〜4)、この上限を早期に解放される待ちへ変えます。リーダーはアクセシビリティイベントの流れを観測し、GET /source のたびに、そのダンプ時点で最新のイベントのデバイス時刻を X-Bajutsu-Read-Mark ヘッダとして刻印し、さらに GET /clock エンドポイントを備えます。ドライバは、待ちを張るアクチュエーションのたびにデバイス時刻のマークを取り、そのマークより後の読み取りを要求します(read_postdates_actuation()ReadOrderProvider の継ぎ目)。こうして座標解決の追いつきは、予算いっぱいまで待つ代わりに、デバイスがアクションの更新を公開した瞬間に解放されます(滞留はありません)。マークが解放するのは座標のバリアだけです。シナリオ途中の extract は実時間の予算を保ちます。マークが答えるのは「アクセシビリティイベントがジェスチャより後である」ことであり、extract が必要とするのは「写し取る対象のプロパティが再公開済みである」ことだからです。1 回のジェスチャは複数のイベントを生みます。Compose は、新しいカウントを映す Text が再コンポーズされるより先に、タップされたボタン自身のイベントを publish します。そのため読み取りがタップより後でありながら、以前の値を持ったままになり、次の読み取りとも一致してしまいます。順序は、座標解決が待つ frame に対しては正しい問いですが、値に対しては誤った問いです。2 つのマークはどちらもデバイス自身の時計から取るので、ホストとデバイスのあいだの時計のずれは入りません。GET /source?since=<mark> は、同じ順序づけをリーダー自身へ持ち込みます。要求したマークより後のアクセシビリティイベントが届くまでブロックし、続いて有界な settle が tearing を閉じてから応答するので、2 回同一ダンプによる鮮度の判定はその源で退き、settle 済みの画面で 2 回目のダンプを払わなくなります。予算が残るのは、マークを持たない一発の uiautomator dump のフォールバックだけです。この読み取りマークの契約(読み取り順序を持つバックエンドでは、読み取りが内容を動かすジェスチャより後になること)を、ドライバ適合性スイート(BE-0114)が実バックエンドに対して検証します。 - 実機アクチュエーションの忠実度(ロードマップ BE-0210): back ステップは真のシステムバック(input keyevent 4KEYCODE_BACK)です。Android にはタップできる画面上の戻る要素がなく、この点が iOS の OS 戻るボタンと異なります。double_tap の主経路は resident server の POST /act(前述)です。server は両方の MotionEvent を自ら生成し、round trip のたまたまの timing に double-tap ウィンドウへの着地を委ねる代わりに、宣言した interval をその間に刻みます(ロードマップ BE-0339)。この channel がないときは、host 側の手順にフォールバックします。root 化されたデバイスでタッチスクリーンを検出できる場合は、生の 2 スロット sendevent シーケンス(BE-0208)が 2 回のタップの間隔をプロセス起動 5 回分まで狭めます。それ以外では、2 回のタップを単一の adb shell 往復input tap … ; input tap …)で発行するので、adb の転送往復自体が間隔を double-tap ウィンドウの外へ広げることはありません。タップの対象が現在のビューポートにないときは、そちらへスクロール(既定は上方向のスワイプ)して再クエリし、回数で区切ります。固定 sleep ではなく条件待機なので、決して現れないセレクタはそれでも決定論的に失敗します。

[!NOTE] この not-found 時のスクロール回復は adb 専用です。XCUITest/Playwright は対象が初期ビューポートにないと tap を即座に失敗させます。そのため、これに頼ると、同じシナリオでも fold より下の要素への tap が Android では通り、iOS/web では失敗する非対称が生じます。画面外の要素へ届く移植可能な方法は、明示的な scroll アクション(BE-0326)です。iOS、Android、web で同じように対象を現す、決定論的で慣性のない 1 つの構文で、scroll: { to: <selector> } としてから対象を操作します。showcase フィクスチャがかつて使っていた手作業調整の swipe 連鎖を置き換えます。adb の自動スクロールは、not-found のケースに限った tap の下の安全網として残ります。移植可能な書き方そのものではありません。

これとは別の、より狭い安全網が、いまでは点のヒットテストができる全バックエンド(アプリ組み込みの WebView ブリッジ WebContextDriver を除きます。そのプロトコルは点のヒットテストを一切公開していません)を覆っています。tap / double_tap / long_press は、操作の前に、解決済みの対象が実際にその点で到達可能か(他の画面上の要素に覆われていないか)を確認します。各プラットフォームがもっとも自然に提供する手段(iOS はネイティブの isHittable、web は document.elementFromPoint によるヒットテスト、adb はドキュメント順による幾何学的な近似、Driver.is_tappable / topmost_at_point)を使います。この確認に失敗すると、オーケストレータが小さく回数を区切ったスクロールを試します。まず down 方向へ最大 3 回、それでも対象に到達できなければ up 方向へ最大 6 回まで試したうえで(updown が残した分をまず巻き戻してからでないと自分の分の前進ができないため、上限を広げています)、操作をもう一度だけ再試行します。即座に失敗するわけではありません。詳細は selectors.mdを参照してください。これは上記の明示的な scroll アクションの代替ではありません。対象が最初から画面外にあるとすでに知っている作者は、それでも自分で scroll を書きます。この仕組みが働くのは、作者が予期していなかった遮蔽(一時的なオーバーレイ、位置が定まりきっていないスティッキーヘッダーなど)に対してだけです。adb 自身の not-found フォールバックが、予期しない画面外の対象に対してすでに同じ役割を果たしているのと同じ考え方です。 - マルチタッチ(BE-0232): pinch / rotate は 2 スロットの protocol-B sendevent スイープで駆動します(pinch_contacts / rotate_contacts が 2 接点の座標を計算し、rotate は両端点を結ぶ直線の弦を掃きます。円弧の線形近似で、web バックエンドの rotate と同じです)。rooted device でタッチスクリーンを検出できることが前提で、_two_finger_gesture はそれ以外では UnsupportedAction で明確に失敗します。下の double-tap 経路と異なり、単一タッチへのフォールバックはありません。MULTI_TOUCH は root の有無にかかわらず能力集合で静的に宣言するので、preflight は adb 上の gestures_multitouch を通します。root のチェックは能力集合ではなく実行時に課します。 - screenshotadb exec-out screencap -p の PNG バイト列(バイナリを崩さない stdout)を書き出します。 - ライフサイクル(AndroidEnvironment、iOS の simctl シーケンスの双子): 起動完了待ち(getprop sys.boot_completed を有界の期限まで polling する条件待機で、固定 sleep も、無期限にブロックする adb wait-for-device もありません)→ 必要に応じて APK インストール → pm clear によるクリーン状態(erase 相当)→ am force-stop → ランタイム権限の事前付与(pm grant、後述)→ am start(起動アクティビティはパッケージマネージャで解決し、launch env は intent extras として渡します)→ deeplink(am start -a android.intent.action.VIEW)。run の manifest は backend: "adb" を記録するので、選ばれた actuator が開示されます。 - ランタイム権限(BE-0210): ターゲットの config grantPermissions に列挙した権限を、lease 時に adb shell pm grant <package> <permission> で事前付与します。pm clear(付与をリセットします)のあと、起動の前に付与するので、ランタイムの権限プロンプトがシナリオを止めることがありません。ダイアログが現れてからタップするのではなく、事前に決定論的に付与することで、タイミングを run の経路に持ち込みません。列挙する権限はアプリごとに異なるので、ドライバではなく config に置きます。 - 区間証跡(BE-0007 の Unit 4): videoadb shell screenrecord で録画し、deviceLogadb logcat をストリームします。simctl の provider の双子です。screenrecord はデバイス側に書き込む(ホストのファイルへは流せない)ので、録画は停止時に SIGINT で確定させてから adb pull で回収し、logcat はファイルへストリームして SIGTERM で停止します。どちらも web バックエンドと同じ driver の driver_interval seam から供給するので、バックエンド非依存の capture ポリシーがそのまま両方を運びます(evidenceを参照)。 - ネットワークはネイティブには観測しません(NETWORK 能力を持ちません)。iOS と同じモックで対応し、アプリ側の collector の URL を launch env 経由で intent extra として渡すので、新しいコードパスなしに mocks が動きます。デバイス制御は、エミュレータが実現できるサブセットとして setLocationemu geo fix、BE-0211)とクリップボード操作に対応します。一族の残りは未対応のままです。クリップボードは cmd clipboard ではなくアプリ内のレシーバ(BajutsuAndroid、BE-0233)を経由します。このコマンドは実機では黙って何もせず、Android 10 以降はフォアグラウンドのアプリと既定の IME しかクリップボードを触れないためです。そこで bajutsu は順序付き am broadcast を送り、アプリの内側のレシーバがアプリプロセスからこれを処理します(両方向を base64 で運ぶので argv に引用符付けは要らず、レシーバがなければ空のクリップを読むのではなく明確に失敗します)。XCUITest がクリップボードを simctl 経由で実現するのと同じく、協調するアプリがあればバックエンドが駆動できるので、adb は clipboard を公開し続けます。BajutsuAndroid を参照してください。

XML の属性名は UI Automator の uiautomator dump スキーマに従います。Views の android:id における ._ の扱いはシナリオ側で解決します。セレクタが id を両方の形で持ち、どちらにもマッチします(BE-0221)。そのため共有の showcase シナリオが両 Android toolkit でそのまま走り、android-e2e.ymlshowcase-composeshowcase-views を同じセットで駆動して push/PR ごとに検証します。fast ゲートでは、取得済みの XML フィクスチャに対してパーサ、frame 中心タップ、transient-empty のリトライ、ambiguity 即失敗を検証します。adb は brew install android-platform-tools でインストールします。

Flutter(ネイティブバックエンド経由)

Flutter アプリは、既存の XCUITest/adb バックエンドをそのまま使って駆動します。Flutter は自前のバックエンドを追加しません(ロードマップ BE-0008)。Flutter は Skia/Impeller で自前のピクセルを描きますが、ネイティブバックエンドはピクセルを一切読まず、OS のアクセシビリティツリーを読みます。Flutter はそこへ自身の semantics ツリーをエンジンが橋渡しします(Android の AccessibilityBridge は各 SemanticsNode を仮想の AccessibilityNodeInfo に変換し、iOS のエンジンは UIAccessibility 要素を公開します)。したがって Semantics(identifier: …) を設定したウィジェットは両バックエンドで解決可能な要素として現れ、bounds 中心への tap は semantics ノードの画面上の矩形と Flutter 自身のヒットテストを通じて着地します。セレクタモデル、機械アサーション、ランナーはバイト単位でそのまま変わりません。

id 規約は、上の iOS と Android のものと並べて記します(Flutter 3.19 以降。このバージョンで SemanticsProperties.identifier がプラットフォームのツリーへ写り始めました)。

Selector フィールド Flutter(ネイティブバックエンド経由)
id(主) Semantics(identifier: "…")accessibilityIdentifier(iOS)/ resource-id(Android)
label(補助) ウィジェットの semantics ラベル(可視テキスト)
value ウィジェットの semantics value(状態のミラー、Semantics(value: …)
traits(役割フィルタ) プラットフォームのウィジェットクラス/trait として公開される semantics の役割(buttonselected など)

アプリが満たすべき前提が 2 つあります。いずれもレンダラではなく Flutter の semantics の状態に関わるものです。

  • semantics は遅延構築されます。 Flutter はアクセシビリティのクライアントが接続したとき、またはアプリが SemanticsBinding.instance.ensureSemantics() を呼んだときにだけツリーを組み立てます。両バックエンドとも接続が構築の引き金になります。Android では UI Automator がアクセシビリティサービスとして接続し、そしてこの項目が実機で確かめたとおり、iOS でも XCUITest ランナーのアクセシビリティ照会が引き金になります。したがって駆動される経路に ensureSemantics() の呼び出しは要りません。showcase アプリは、既定でオフの --dart-define=ENSURE_SEMANTICS=true の裏にこの呼び出しを残し、アクセシビリティのクライアントなしで駆動されるアプリのための文書化されたフォールバックとしています。
  • semantics を持つウィジェットしか現れません。 標準の Material/Cupertino ウィジェットやテキストは semantics を自動で持ちますが、CustomPaint で描いたコントロールを Semantics で包んでいなければツリーに入りません。Semantics(identifier: …) で包むことは、id を公開する規約と同じ操作です。Flutter はナビゲーションの見た目も自前で描くので、アプリは戻るコントロールの identifier も、iOS バックエンドの back ステップがタップするプラットフォーム規約 BackButtonbase.OS_BACK_BUTTON)に設定します。Android ではシステムの戻るキーが従来どおり pop します。

実機検証済みです。 ネイティブ showcase アプリの Flutter 双子(demos/showcase/flutter)を showcase-flutter(iOS、XCUITest)と showcase-flutter-android(Android、adb)のターゲットで駆動し、共有の scenarios/ セットがそのまま走ります。id ベースのセレクタ、状態ミラーに対する value アサーション、遅延構築(culling)される Notices リストへの scroll-to-element、ネイティブの 2 本指 pinchrotate を確認しました。make -C demos/showcase run-flutter(iOS)/ run-flutter-android(Android)で実行します。

スコープ外(理由はロードマップ項目を参照してください):

  • アプリ内 collector/receiver ライブラリを必要とする機能。 2 つの機能は、BajutsuKit(iOS)/ BajutsuAndroid(Android)がアプリにリンクされていることに依存します。Flutter アプリはプラグインなしを保つためこれらをリンクしていません。
  • network の捕捉と mocks はアプリ内のインターセプタ(iOS は BajutsuKitURLProtocol、Android は BajutsuAndroid の OkHttp インターセプタ)に通信を通します。Flutter の Dart HttpClient はそのどちらも通らないので、network 証跡と mocks は Flutter の通信を観測しません。アプリの *.status ミラーは、シナリオが依拠する決定論的な wait/assert を引き続き駆動します。Dart の HTTP をネイティブのスタックへ通すこと(cupertino_httpcronet_http 経由)は後続作業です。
  • Android の device-control clipboardBajutsuAndroid のアプリ内レシーバ(BE-0233)を往復するので、device.yamlsetClipboardclipboard ステップは Flutter Android ターゲットで失敗します。iOS の device-control クリップボードはアプリの協力なしに simctl を通るので、Flutter iOS ターゲットでは動きます。この差は Android だけのものです。

これらを除けば、Flutter ターゲットはネイティブ双子が回すのと同じ実機シナリオ群を通ります。Flutter とは無関係にプラットフォームで制限されるものだけが外れます。マルチタッチ(gestures_multitouch)は adb では root 化したエミュレータを要し(ネイティブ Android アプリと同様)、text_editingdevicepush 部分はネイティブのスイートでも iOS 専用です。 - ディープリンクからタブへのルーティング。 Flutter ターゲットはフレーバごとのスキームを登録します(Android の VIEW intent-filter、iOS の CFBundleURLTypes、各ターゲットの deeplinkScheme)。ただし、URI をタブへルーティングする(タブを選び、ルートまで戻し、開いているモーダルを閉じる)ネイティブ双子とは違い、Flutter アプリはまだ受け取った URI を処理しません。スキームを登録しているのは、BE-0007 のディープリンク実行の後続スライスが am start -a VIEW -d <scheme>://<tab>simctl openurl で駆動できるようにするためで、アプリ側のハンドラはそのスライスで入ります。リテラルスキームのディープリンクを駆動する共有シナリオは今はなく(navigation.yamlnotices.yaml は launch env とタップだけで駆動します)、実機検証には影響しません。 - Flutter Web(CanvasKit)。 canvas へ描き、要素を DOM に出さないので、Playwright バックエンドでは解決できません。 - iOS の noax 双子。 a11y ビルドが公開の実証です。identifier を持たない別の iOS バンドルには Flutter フレーバによる bundle id の分離が要り、後続作業とします。Android の noax 双子は同梱します(showcase-flutter-android-noax)。Gradle のプロダクトフレーバでビルドします。

Playwright(web)

Playwright(Python)によるヘッドレス Chromium です。Mac も Simulator も要らず Linux で動くため、make check と同じツールチェーンに収まります。実装: drivers/playwright.py(ロードマップ BE-0041)。

  • query(): 1 本の page.evaluate() が、可視、操作可能、アクセシビリティ関連の DOM ノードを走査し、純粋なパーサ(parse_dom)が各ノードを Element に写像します。id 規約は iOS の accessibilityIdentifier の web 版です。data-testidSelector.id、ARIA role(またはタグ)→ traits、accessible name / aria-label / テキスト → label、input の valuevalue
  • tap(sel): adb バックエンドと同様、query() のスナップショットに対し共有の resolve_unique/find_all で要素を一意に確定し、frame 中心を座標クリック(page.mouse.click)します。Playwright 自身の get_by_test_id().click()あえて使いません。これによりセレクタの意味が他のどの backend ともバイト単位で一致します。
  • type_textpage.keyboard で入力します(オーケストレータが先に into をタップしてフィールドにフォーカスします)。screenshotpage.screenshotwait_forfind_all による単発です(どの backend も同様で、締め切りまでのポーリングは共有ヘルパ base.wait_until が担います)。
  • ライフサイクルは driver が所有します。新しい BrowserContexterase 相当、navigate()page.goto(baseUrl))が launchclose() でブラウザを破棄します。simctl のデバイスは無いので、run はダミーのリースを使い、device control は持ちません。
  • デバイスモード(BE-0228): web ターゲットの deviceMode 設定が、各 BrowserContext の生成のしかたを選びます。desktop(既定で、素のデスクトップコンテキスト。従来と変わりません)か、Playwright のデバイスプリセット名(例 iPhone 13)です。プリセットは playwright.devices に対して解決し、その記述子(viewport / device_scale_factor / is_mobile / has_touch / user_agent)を reduced_motion="reduce" と並べて new_context(**kwargs) にマージするので、ターゲットをそのモバイル端末として駆動します。記述子は遅延して解決し(config 読み込みが Playwright を import することはありません)、記憶するので、reset_context(クロールのクリーンな起点)と relaunch(BE-0077)は同一のコンテキストを組み直します。モードはブラウザのライフサイクル全体を通して安定し、エンジンや reduced_motion が既に守っているのと同じ不変条件です。不明なプリセットはドライバー起動時に ValueError で明示的に失敗します。これはデスクトップ級ブラウザでのエミュレーションであり(デスクトップ級ブラウザの中でモバイルの viewport とタッチ入力を用いる、Chrome DevTools のデバイスツールバーが行うのと同じもの)、実機のモバイルブラウザやデバイスクラウドではありません。本物のモバイル OS が必要なら Android バックエンドが経路です。
  • 方向指定の swipe はスクロールになる(BE-0227): 方向指定形式 swipe: { on, direction } の意味は「スクロール」であり、マウスドラッグは web ページをスクロールしません。そこで web バックエンドは、実際にスクロールを起こす入力プリミティブへ、コンテキストの入力モード(上記の deviceMode)に応じて振り分けます。デスクトップ(ポインター)コンテキストでは、ジェスチャーの起点で page.mouse.wheel(...) を発火します。wheel の移動量は travel の符号を反転したものなので、up の swipe はページを下へスクロールさせ、トラックパッドやホイールとまったく同じ挙動になります。タッチコンテキスト(モバイルの deviceMode)では、CDP による 1 本指の本物のタッチドラッグ(pinch / rotate と同じ経路)を使い、ページのタッチリスナとスクロールリスナが発火します。座標形式 swipe: { from, to } は変わりません。canvas やマップのパン、ドラッグハンドルのための素のドラッグの最終手段として、page.mouse のドラッグのままです。codegen も方向指定形式にはデスクトップの wheel スクロールを出力するので、生成された Playwright テストは、従来の何も動かないドラッグではなく、物理的に正しい向きへスクロールします(codegen には amount を掛ける viewport がないため、距離は既定の固定値です)。別途用意した drag アクション(要素アンカーのポインタドラッグ。リサイズ用の仕切りやスライダーなど)はドライバーの swipe に振り分けられるので、web では掴んだ要素を実際に動かす page.mouse のドラッグになります。スクロールするだけの方向指定 swipe とは対照的です。
  • マルチタッチ(BE-0054): pinch / rotate は Chromium DevTools プロトコル(Input.dispatchTouchEvent)で 2 本指のドラッグとして合成します。mouse は単一ポインタなので、ジェスチャは CDP 経由(実際のタッチと同じ経路)で送り、ページのタッチリスナが発火します。要素の中心を 2 本指の基準点とし、scale が指の間隔を広げ/狭め、radians がその中心まわりに回転させます。
  • ネイティブネットワーク(BE-0054): Playwright はページが出すすべてのリクエストを見られるので、--network はアプリ側の協力なしに web でも動きます。network_collector() がページの requestfinished イベントを iOS と同じ NetworkExchange に変換するため、request アサーションも network.json 証跡もそのまま使えます。シナリオの mockspage.route でその場で fulfill します。一致したリクエストには既定のレスポンスを返し、mocked: true を立てて記録します。一致判定は決定論的な request マッチャを再利用し、モデルは一切使いません。
  • コンソール / ページエラー、動画の証跡(BE-0054): deviceLog キャプチャ種別はブラウザのコンソールと未捕捉のページエラーを <scenario>/device.log にストリームし、video はシナリオ全体を録画します。どちらも simctl ではなく Playwright ネイティブで、iOS の os_log / simctl 動画に相当します。録画はシナリオの capturevideo がある時だけ有効化し(BrowserContextrecord_video_dir 付きで生成)、video インターバルが context クローズ時に <scenario>/scenario.mp4(中身は webm)へ確定させます。プールがドライバの driver_interval(adb バックエンドと共有する、driver 供給の区間証跡 seam)を FileSink に注入するので、バックエンド非依存の同じ capture ポリシーが両方を運びます。

playwright遅延 import されます(実際にブラウザを起動するときだけ読み込む)。そのため既定の CLI パスには決して載りません(tests/serve/test_import_guard.py で固定)。インストールは uv sync --extra webuv run playwright install chromiumdemos/web のデモ(make -C demos/web e2e)が小さな静的 web アプリを端から端まで駆動します。

FakeDriver

実機なしで orchestrator / runner / record をテストするためのインメモリ実装です。実装: drivers/fake.py

  • screenElement のリスト)を保持し、query() で返します。
  • tap / long_press は本物同様 resolve_unique を通します(曖昧 / 不在は SelectorError)。
  • react コールバックで「操作に応じて画面が変わる」動作をスクリプトできます。
  • actions に実行した操作を記録します(検証用)。
def react(driver, kind, arg):
    if kind == "tap":
        driver.screen = [...]  # タップ後の画面に差し替える
FakeDriver(screen=[...], react=react)

バックエンド選択と actuator

実装: bajutsu/backends.py

PLATFORMS = {                              # プラットフォームトークンは actuator 列へ展開(安定度順)
    "ios":     ("xcuitest",),              #   BE-0290 で idb を撤去して以来、iOS の唯一の actuator
    "android": ("adb",),                   #   Android の唯一の actuator(BE-0007)
    "web":     ("playwright",),            #   実装済み(BE-0041)
    "fake":    ("fake",),                  #   メモリ上のテスト/デモ用ドライバ
}
COST_ORDER: dict[str, tuple[str, ...]] = {}  # 空。どのプラットフォームもコスト順が安定度順と食い違わない
IMPLEMENTED = {"fake", "playwright", "xcuitest", "adb"}  # 今日ドライバがある actuator

def default_available(actuator) -> bool:   # 実装済みかつ裏のツールがあるか(playwright はパッケージ import、fake は常に可)
def resolve_actuators(backends) -> list:   # 各トークン(プラットフォーム/actuator)を actuator 列へ展開
def select_actuator(backends, available) -> str:  # 安定度順で最初の「実装済み かつ 利用可能」
def select_actuator_cost_first(backends, available) -> str:  # シナリオ無しで、最も安い利用可能な actuator(BE-0267)
def select_actuator_for_scenario(backends, scenario, available, caps) -> str:  # 利用可能かつ十分な、最も安い actuator(BE-0240)
def make_driver(actuator, udid, *, base_url=None, runner_port=None) -> Driver:  # "xcuitest"→XcuitestDriver, "playwright"→PlaywrightDriver, "fake"→FakeDriver
  • バックエンドトークンは、プラットフォームios / android / web / fake)か、具体的な actuator(例: xcuitest)のどちらかです。現状はどのプラットフォームも単一の actuator に解決します。iosxcuitest(BE-0290 で idb を撤去したので --backend ios--backend xcuitest は等価)、androidadbwebplaywright です。actuator を複数持つプラットフォームのための仕組み(シナリオごとのコスト順解決。BE-0240)は将来のプラットフォームに備えて残していますが、今それを使うプラットフォームはありません。
  • 二つの順序が二つの問いに答えます。安定度順PLATFORMS、最も高機能なものから。concepts)は select_actuator を駆動します。これは、まだシナリオが手元に無くコストも問わない場面(doctor、プールの起動時セットアップ、明示的な単一 actuator の固定)で使う、可用性だけの選択です。コスト順COST_ORDER、最も安いものから)は select_actuator_for_scenarioselect_actuator_cost_first の両方を駆動し、両者は候補解決の前段(_cost_ordered_available)を共有します。COST_ORDER が空になった今、プラットフォームのコスト順は安定度順そのものなので、いずれも単一候補に落ち着きます。select_actuator_for_scenario はさらに capability_preflight.unsupported(BE-0082)を各候補の能力集合に対して再利用し、利用可能かつそのシナリオのステップに十分な最初の候補を返します。select_actuator_cost_first は同じコスト優先の選択をシナリオ無しで行うもので、能力の昇格判定なしに「立ち上げられる中で最も安い actuator」だけが必要な場面(serve の Author タブの CaptureEnrich。BE-0267)で使います。どちらも、解決した候補が 1 つに収まる場合は select_actuator に委譲し(その診断メッセージを保ちます)、現状はどのプラットフォームも単一 actuator なので常にそうなります。利用可能なものが無ければ RuntimeError(CLI は終了コード 2)。
  • webplaywright に、androidadb に解決され、どちらも実装済みです (vision → reach)。本当に未知のトークンはスキップされます(前方互換: 古いビルドでも、将来のバックエンドを列挙した config を実行できます)。
  • 可用性判定 available は注入可能です(テストで差し替え可)。既定は shutil.whichfake は実行ファイル不要で常に利用可能)。
  • actuator はシナリオごとに 1 つ確定し、そのシナリオの実行のあいだ固定です(BE-0240)。どの瞬間もリースしたデバイスを操作する actuator はちょうど 1 つで、実行の途中で切り替わりません。これは従来の「run ごとに固定」という単位をシナリオ単位へ狭めたもので、単一 actuator の規則を緩めるものではありません。

操作は単一の actuator にとどまります。リスト内の非 actuator バックエンドは、read-only な証跡フォールバックとして機能します(DESIGN §9、BE-0020)。その種別の provider には、actuator が欠く能力(例: Capability.NETWORK)を capabilities() で表明する同一プラットフォームのバックエンドを充てます。フォールバックには狭い EvidenceProvider Protocol 経由でのみアクセスし、tap/type/swipe は型レベルで不可能です。gap を埋めるバックエンドが無い場合は、理由を記録して skip します(SkippedCapture)。なだらかな劣化であり、run の失敗にはなりません。来歴の詳細は証跡の providerを参照してください。

環境管理(simctl)

実装: bajutsu/simctl.py。コマンドビルダは純関数(単体テスト済み)で、実行は注入可能な RunFn 経由です。

メソッド コマンド 備考
erase() simctl erase <udid> クリーン環境
boot() simctl boot <udid> 既に boot 済みなら冪等(エラーを握りつぶす)
launch(bundle, args, env) simctl launch --terminate-running-process <udid> <bundle> <args> env は SIMCTL_CHILD_* で注入
terminate(bundle) simctl terminate <udid> <bundle> 未起動でも無視
openurl(url) simctl openurl <udid> <url> deeplink
screenshot(path) simctl io <udid> screenshot <path>

すべての呼び出しに期限がありますBE-0363): 共有のランナーは、一回きりの simctl サブプロセスすべてに期限を渡します。値はコマンド自身から選ばれます。所要時間を simctl ではなくデバイスやアプリが決めるコマンド(bootstatusbooteraseinstall)には長い側を、それ以外には短い側を当てます。CoreSimulator が固まったときに観測される症状、すなわち返ってこない呼び出しは、CI がジョブ全体を原因不明のまま打ち切るまでハングするのではなく、超過した期限とコマンドを名前で示す simctl.DeviceTimeout を送出します。DeviceTimeoutDeviceError の派生なので、デバイス障害を変換している既存のハンドラは変更なしで動きます。どこで受け止めるかは呼び出し側で異なります。ベストエフォートのプローブ(device_booteddevice_availabledevice_catalog など)は、文書化されたフォールバックへ畳み込んだうえでログに残すので、復旧ラダーは観測結果で判断し続けられます。それ以外の呼び出しは送出します。冪等な shutdown / boot / uninstall / terminate も同様で、これらが握りつぶすのは失敗した呼び出しであって、ハングした呼び出しではありません。

launch env の注入: アプリへ渡す env 変数は、親プロセスに SIMCTL_CHILD_<NAME> として設定すると子(アプリ)に <NAME> で渡ります。child_env() がこの変換を行います。showcase アプリの SHOWCASE_UITEST 等の launch hook はこの仕組みを使います(showcase)。

video / deviceLog の区間録りも simctl io recordVideo / simctl spawn log stream を使いますが、これらは証跡サブシステム側(evidence/intervals.py)に置かれています(evidence)。