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.py・bajutsu/simctl.py・bajutsu/adb.py。
関連: selectors(解決) · concepts の安定度順ラダー · run-loop
Driver Protocol¶
すべてのバックエンドが満たす共通インターフェースです(base.py、runtime_checkable な Protocol)。
操作(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()を直接ポーリングして行います(_wait、run-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 エミュレータはsetLocationとclipboardだけを実現します(そのpush/ キーチェーン / ステータスバー / アプリライフサイクルの各操作には忠実な等価物がありません)。adb はリーンな端に位置し、frame 中心の座標で操作します。semantic tap を持たないため、run ループは
query()で要素を一意に確定しその中心をタップします。一方 XCUITest は豊かな端に位置し、identifier で直接 tap し、ネイティブの条件で待機し、pinch/rotateをネイティブに実行します。adb はquery/elements/screenshot、multiTouch(rooted device でのsendevent2 本指スイープ、BE-0232)に加えて、エミュレータが実現できるデバイス制御のサブセットdeviceControl.setLocationとdeviceControl.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 / rotate は multiTouch、selectOption は selectOption トークン(web だけで通る <select> の切り替え。iOS / Android はデバイス作業の前に拒否されます)、select / copy は textSelection(全選択とクリップボードへのコピー。web コンテキストはこれらについて座標専用でどちらも拒否します。delete / clear はどのバックエンドも delete_text を実現できるので門にしません)、visual アサーションは screenshot、handleSystemAlert は handleSystemAlert トークン(xcuitest だけが公開します)、setPickerValue は pickerWheel トークン(こちらも xcuitest だけです。ホイール型のピッカーは iOS ネイティブのコントロールなので、Android と web はデバイス作業の前に拒否されます)、そして各デバイス制御ステップは自分の操作に対応するトークンを必要とします。setLocation は deviceControl.setLocation、クリップボードのステップは deviceControl.clipboard、push は deviceControl.push、という具合です(この操作ごとのトークンへの分割は BE-0212、前掲)。permissions の各エントリも同様に、サービスごと(deviceControl.permissions.<service>)の門を通すので、未対応のサービスはフィールド全体としてではなく1 つずつ名指しされます。すべての run は query と elements を必要とします。relaunch はここに含みません。relaunch は DeviceControl ではなく、注入される 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 エミュレータの setLocation と clipboard)は、公開した操作については preflight を通し、残りについては速く失敗します。未対応のステップは1 つずつ名指しされ、一族が全か無かで扱われることはありません。
XCUITest(iOS)¶
BE-0290 で idb を撤去して以来、iOS の唯一の backend です。XCTest のオートメーションスナップショットを、実機上に常駐する runner(BajutsuKit)を 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.testRunnerもxcuitest.buildも指定しないときは、wheel にパッケージデータとして同梱された Simulator 用 runner に解決します(BE-0292)。明示的なtestRunnerやbuildは依然としてこの既定より優先し、deviceType: deviceは引き続き明示的な署名済み runner を必要とします。署名済み runner はオペレーターのチーム向けに Bajutsu が同梱できないためです。この backend はmake -C demos/showcase run-swiftui+ios-e2e.ymlCI ワークフローで実機検証済みです(iPhone 17 Pro、最近の iOS)。XCUITest backend は pip extra を必要とせず、xcodebuildは Xcode が供給します。
adb(Android)¶
ヘッドレスで座標ベースの、唯一の座標系バックエンドです。semantic tap を持たないため、抽象側で id → frame 中心 → 座標 tap に解決します。実装: drivers/adb.py + bajutsu/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_RESIDENT(0/1)でどちらの経路にも固定できます。- セレクタの写像:
resource-id→identifier(<package>:id/接頭辞を剥がしてローカル名にするので、testTagsAsResourceIdで表出した Compose のtestTagはそのまま、ネイティブのandroid:idは接頭辞が落ちます)、text→label(content-descへフォールバック)、content-desc→value(アプリは状態値をここにミラーします。SPEC §2.1)、ウィジェットのclass(と enabled / selected / checked の状態)→traitsです。 - id 照合ポリシー: ローカル名は厳密一致で照合します。ドライバは
.↔_の書き換えをしません。書き換えは別々の id を取り違え、決定性を損なうためです。プラットフォーム本来の id 構文が SPEC の id をそのまま再現できない場合(Android Views のandroid:idは.も-も許さず、stable.refreshはstable_refreshとして現れます)は、シナリオが 1 つのセレクタに id を両方の形で持ち(id: [stable.refresh, stable_refresh])、候補の OR として照合します(BE-0221)。scenarios を参照してください。 tap(sel):_resolveで一意性を確定します(not-found はリトライ、ambiguity は即失敗。遷移中のダンプは一時的な null-root としてリトライし、2 件以上の一致は即座に失敗させます)。確定後、tap・long_press・double_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 tap、long_pressは duration だけ保持する同一点の swipe)。server が inject したにもかかわらず、その応答が host へ届かなかったときは、2 回目の touch が 1 回目に重なって着地する危険を避けるため、driver はそのジェスチャを完了扱いにします。double_tapのデバイス側経路は、server 側の 1 回の呼び出しから両方のMotionEventを生成し、round trip のたまたまの timing に間隔を委ねる代わりに、固定した interval を宣言します。その座標フォールバックは後述の実機アクチュエーションの忠実度を参照してください。swipeは実際のドラッグにするため有限の duration を付け、type_textはinput text(空白はその%sエスケープで送ります)です。- 座標の解決は、pan にツリーが追いつくまで待ちます。 Android はまず内容を動かし、新しい frame を伝えるアクセシビリティ更新をその後に publish します。その 2 つの時点のあいだに当たった読み取りは、スクロール前の画面を返します。しかも読み取りを繰り返しても、すでに誤っている frame について互いに一致します。ツリーは揺れて見えるのではなく、誤ったまま自己整合しているのです。そのため、連続 2 回の読み取りの一致を settle と見なす判定だけでは、遅れを検出できません。そこで frame をまとめて動かすジェスチャ(
swipe、scroll、pinch、rotate)の直後、さらにデバイス側か座標かを問わずあらゆる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 です。確認はデバイスが与えるものであって、ドライバが仮定してよいものではありません。同一性で指すフォロワーと違い、座標で解決するフォロワー(pinch、rotate、方向付きswipe/dragのアンカー)には、自己修復のためのstale再解決がないからです。バリアの待ち時間には実時間の上限を設け、使い切ったときはその旨を警告として残します。この上限は、scrollのループが内容の末尾を確認してから失敗するために使う値と同一です(ReadLagProvider、BE-0326 / BE-0332。architecture を参照)。publish の遅れは 1 つなので、予算も 1 つにして複数の経路で使います。方向付きのswipeとdragだけは、解決の場所が異なります。この 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 のあいだにランナーがすでに取る読み取り(wait、assert、ステップ後のキャプチャ)が通常はこの判定を満たすので、ツリーが追いついている実行は何も待ちません。
この待ちによって、継続的インテグレーションのエミュレータで 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 4、KEYCODE_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 回まで試したうえで(upはdownが残した分をまず巻き戻してからでないと自分の分の前進ができないため、上限を広げています)、操作をもう一度だけ再試行します。即座に失敗するわけではありません。詳細はselectors.mdを参照してください。これは上記の明示的なscrollアクションの代替ではありません。対象が最初から画面外にあるとすでに知っている作者は、それでも自分でscrollを書きます。この仕組みが働くのは、作者が予期していなかった遮蔽(一時的なオーバーレイ、位置が定まりきっていないスティッキーヘッダーなど)に対してだけです。adb 自身の not-found フォールバックが、予期しない画面外の対象に対してすでに同じ役割を果たしているのと同じ考え方です。 - マルチタッチ(BE-0232):pinch/rotateは 2 スロットの protocol-Bsendeventスイープで駆動します(pinch_contacts/rotate_contactsが 2 接点の座標を計算し、rotateは両端点を結ぶ直線の弦を掃きます。円弧の線形近似で、web バックエンドの rotate と同じです)。rooted device でタッチスクリーンを検出できることが前提で、_two_finger_gestureはそれ以外ではUnsupportedActionで明確に失敗します。下の double-tap 経路と異なり、単一タッチへのフォールバックはありません。MULTI_TOUCHは root の有無にかかわらず能力集合で静的に宣言するので、preflight は adb 上のgestures_multitouchを通します。root のチェックは能力集合ではなく実行時に課します。 -screenshotはadb 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): ターゲットの configgrantPermissionsに列挙した権限を、lease 時にadb shell pm grant <package> <permission>で事前付与します。pm clear(付与をリセットします)のあと、起動の前に付与するので、ランタイムの権限プロンプトがシナリオを止めることがありません。ダイアログが現れてからタップするのではなく、事前に決定論的に付与することで、タイミングを run の経路に持ち込みません。列挙する権限はアプリごとに異なるので、ドライバではなく config に置きます。 - 区間証跡(BE-0007 の Unit 4):videoはadb shell screenrecordで録画し、deviceLogはadb logcatをストリームします。simctl の provider の双子です。screenrecordはデバイス側に書き込む(ホストのファイルへは流せない)ので、録画は停止時に SIGINT で確定させてからadb pullで回収し、logcatはファイルへストリームして SIGTERM で停止します。どちらも web バックエンドと同じ driver のdriver_intervalseam から供給するので、バックエンド非依存のcaptureポリシーがそのまま両方を運びます(evidenceを参照)。 - ネットワークはネイティブには観測しません(NETWORK能力を持ちません)。iOS と同じモックで対応し、アプリ側の collector の URL を launch env 経由で intent extra として渡すので、新しいコードパスなしにmocksが動きます。デバイス制御は、エミュレータが実現できるサブセットとしてsetLocation(emu 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.ymlがshowcase-composeとshowcase-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 の役割(button、selected など) |
アプリが満たすべき前提が 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ステップがタップするプラットフォーム規約BackButton(base.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 本指 pinch/rotate を確認しました。make -C demos/showcase run-flutter(iOS)/ run-flutter-android(Android)で実行します。
スコープ外(理由はロードマップ項目を参照してください):
- アプリ内 collector/receiver ライブラリを必要とする機能。 2 つの機能は、
BajutsuKit(iOS)/BajutsuAndroid(Android)がアプリにリンクされていることに依存します。Flutter アプリはプラグインなしを保つためこれらをリンクしていません。 networkの捕捉とmocksはアプリ内のインターセプタ(iOS はBajutsuKitのURLProtocol、Android はBajutsuAndroidの OkHttp インターセプタ)に通信を通します。Flutter の DartHttpClientはそのどちらも通らないので、network証跡とmocksは Flutter の通信を観測しません。アプリの*.statusミラーは、シナリオが依拠する決定論的な wait/assert を引き続き駆動します。Dart の HTTP をネイティブのスタックへ通すこと(cupertino_http/cronet_http経由)は後続作業です。- Android の device-control
clipboardはBajutsuAndroidのアプリ内レシーバ(BE-0233)を往復するので、device.yamlのsetClipboard/clipboardステップは Flutter Android ターゲットで失敗します。iOS の device-control クリップボードはアプリの協力なしに simctl を通るので、Flutter iOS ターゲットでは動きます。この差は Android だけのものです。
これらを除けば、Flutter ターゲットはネイティブ双子が回すのと同じ実機シナリオ群を通ります。Flutter とは無関係にプラットフォームで制限されるものだけが外れます。マルチタッチ(gestures_multitouch)は adb では root 化したエミュレータを要し(ネイティブ Android アプリと同様)、text_editing と device の push 部分はネイティブのスイートでも iOS 専用です。
- ディープリンクからタブへのルーティング。 Flutter ターゲットはフレーバごとのスキームを登録します(Android の VIEW intent-filter、iOS の CFBundleURLTypes、各ターゲットの deeplinkScheme)。ただし、URI をタブへルーティングする(タブを選び、ルートまで戻し、開いているモーダルを閉じる)ネイティブ双子とは違い、Flutter アプリはまだ受け取った URI を処理しません。スキームを登録しているのは、BE-0007 のディープリンク実行の後続スライスが am start -a VIEW -d <scheme>://<tab> や simctl openurl で駆動できるようにするためで、アプリ側のハンドラはそのスライスで入ります。リテラルスキームのディープリンクを駆動する共有シナリオは今はなく(navigation.yaml や notices.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-testid→Selector.id、ARIArole(またはタグ)→traits、accessible name /aria-label/ テキスト →label、input のvalue→value。tap(sel): adb バックエンドと同様、query()のスナップショットに対し共有のresolve_unique/find_allで要素を一意に確定し、frame 中心を座標クリック(page.mouse.click)します。Playwright 自身のget_by_test_id().click()はあえて使いません。これによりセレクタの意味が他のどの backend ともバイト単位で一致します。type_textはpage.keyboardで入力します(オーケストレータが先にintoをタップしてフィールドにフォーカスします)。screenshotはpage.screenshot、wait_forはfind_allによる単発です(どの backend も同様で、締め切りまでのポーリングは共有ヘルパbase.wait_untilが担います)。- ライフサイクルは driver が所有します。新しい
BrowserContextがerase相当、navigate()(page.goto(baseUrl))がlaunch、close()でブラウザを破棄します。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証跡もそのまま使えます。シナリオのmocksはpage.routeでその場で fulfill します。一致したリクエストには既定のレスポンスを返し、mocked: trueを立てて記録します。一致判定は決定論的なrequestマッチャを再利用し、モデルは一切使いません。 - コンソール / ページエラー、動画の証跡(BE-0054):
deviceLogキャプチャ種別はブラウザのコンソールと未捕捉のページエラーを<scenario>/device.logにストリームし、videoはシナリオ全体を録画します。どちらも simctl ではなく Playwright ネイティブで、iOS の os_log / simctl 動画に相当します。録画はシナリオのcaptureにvideoがある時だけ有効化し(BrowserContextをrecord_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 web+uv run playwright install chromium。demos/webのデモ(make -C demos/web e2e)が小さな静的 web アプリを端から端まで駆動します。
FakeDriver¶
実機なしで orchestrator / runner / record をテストするためのインメモリ実装です。実装: drivers/fake.py。
screen(Elementのリスト)を保持し、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 に解決します。iosはxcuitest(BE-0290 で idb を撤去したので--backend iosと--backend xcuitestは等価)、androidはadb、webはplaywrightです。actuator を複数持つプラットフォームのための仕組み(シナリオごとのコスト順解決。BE-0240)は将来のプラットフォームに備えて残していますが、今それを使うプラットフォームはありません。 - 二つの順序が二つの問いに答えます。安定度順(
PLATFORMS、最も高機能なものから。concepts)はselect_actuatorを駆動します。これは、まだシナリオが手元に無くコストも問わない場面(doctor、プールの起動時セットアップ、明示的な単一 actuator の固定)で使う、可用性だけの選択です。コスト順(COST_ORDER、最も安いものから)はselect_actuator_for_scenarioとselect_actuator_cost_firstの両方を駆動し、両者は候補解決の前段(_cost_ordered_available)を共有します。COST_ORDERが空になった今、プラットフォームのコスト順は安定度順そのものなので、いずれも単一候補に落ち着きます。select_actuator_for_scenarioはさらにcapability_preflight.unsupported(BE-0082)を各候補の能力集合に対して再利用し、利用可能かつそのシナリオのステップに十分な最初の候補を返します。select_actuator_cost_firstは同じコスト優先の選択をシナリオ無しで行うもので、能力の昇格判定なしに「立ち上げられる中で最も安い actuator」だけが必要な場面(serve の Author タブの Capture と Enrich。BE-0267)で使います。どちらも、解決した候補が 1 つに収まる場合はselect_actuatorに委譲し(その診断メッセージを保ちます)、現状はどのプラットフォームも単一 actuator なので常にそうなります。利用可能なものが無ければRuntimeError(CLI は終了コード 2)。 webはplaywrightに、androidはadbに解決され、どちらも実装済みです (vision → reach)。本当に未知のトークンはスキップされます(前方互換: 古いビルドでも、将来のバックエンドを列挙した config を実行できます)。- 可用性判定
availableは注入可能です(テストで差し替え可)。既定はshutil.which(fakeは実行ファイル不要で常に利用可能)。 - 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 ではなくデバイスやアプリが決めるコマンド(bootstatus、boot、erase、install)には長い側を、それ以外には短い側を当てます。CoreSimulator が固まったときに観測される症状、すなわち返ってこない呼び出しは、CI がジョブ全体を原因不明のまま打ち切るまでハングするのではなく、超過した期限とコマンドを名前で示すsimctl.DeviceTimeoutを送出します。DeviceTimeoutはDeviceErrorの派生なので、デバイス障害を変換している既存のハンドラは変更なしで動きます。どこで受け止めるかは呼び出し側で異なります。ベストエフォートのプローブ(device_booted、device_available、device_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)。