Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

AWS Device Farm の adb プロセスを Airtest に認識してもらうための方法

この記事は Akatsuki Games Advent Calendar 2023 10日目の記事です。

昨日は Yusuke Nakajima さんの UnityのProjectWindowに表示履歴機能を付けるエディタ拡張 でした。表示履歴機能があるだけで開発中のストレスが軽減されますね。導入もシンプルにしてくれているのがとても良いと思いました!

zenn.dev

はじめに

QAエンジニアの山﨑@tomo_tk11です。私のチームでは「ゲーム開発を行うプロジェクトメンバーと連携しながら、QA コストや QA リードタイムの最適化」を行っています。 現在はマスターデータのチェッキング作業の効率化や、Airtest と AWS Device Farm(以下、Device Farm) を用いてリグレッションテストなどの自動化に取り組んでいます。

今回は、使用している Airtest のアップデートを行った際に Device Farm で動作しなくなったため、その原因と解決策をまとめました。 Airtest と Device Farm の自動テストシステムで過去に出した資料を合わせて読むことで理解が深まると思います。最後に載せておくのでよければご覧ください。

TL;DR

  • デバイスを指定する uri に serial_noadb_path を入力しよう
# OK
auto_setup(f"Android:///{serial_no}?adb_path={adb_path}")

# NG
auto_setup(f"Android:///")

目次

Airtest アップデートのきっかけ

今回は Airtest v1.3.0 から v1.3.1 へのアップデートを行いました。 最近のAirtest は2ヶ月くらいの間隔でバージョンアップされています。 アップデート内容もかなり積極的でとても助かっています。

  • 最近のアップデートで嬉しかったもの
    • デバイスの画面録画機能で cv2, ffmpeg サポート
    • iOS の自動テストサポートの拡張(今までは一癖ありました)

そして、v1.3.1 では adb に関する気になるアップデートがありました。

adb will now give priority to using the current adb process, or the system variable is set to adb in ANDROID_HOME. If neither is found, adb in airtest will be used.

Release v1.3.1 · AirtestProject/Airtest · GitHub

Device Farm では adb プロセスが既に立ち上がっており、その adb と Airtest に内蔵されている adb が競合するという問題が以前ありました。なので、私たちのシステムではひと工夫しています。

Airtestの FAQ によると、競合した際はpackage内のadbを任意のものに差し替えるとのことです

なのでtestspecのpre_testでinstallしたAirtest package内のadbをDevice Farmのものに差し替えます

hackerslab.aktsk.jp

v1.3.1 のアップデートでそのひと工夫が不要になるのではないかと思い、さっそく自動テストを動かしてみました。

Device Farm 上でテスト実行すると、 ADB Error が発生する

AdbError が発生し、テストスクリプトの実行にすら行きつけませんでした。

airtest.core.error.AdbError: stdout[] stderr[/bin/bash: devices: No such file or directory

原因

このエラーは、Airtest の adb に関する仕様変更による影響です。Device Farm では常に adb プロセスが動作しているという話をしましたが、実際には /bin/bash/opt/dev/android-sdk-linux/platform-tools/adb.orig の2つの実行ファイルが adb を名乗って動いています。 Device Farm は adb の機能に一部制限を加えるために元々の adb バイナリファイルを adb.orig とし、ラッパースクリプト(bash ファイル)を adb として実行しています。今回のアップデートによって Airtest が Device Farm の adb ラッパーの仕組みを吸収できず、adb パスを認識しなくなったためエラーが発生しています。

ちょっとややこしいので深掘りします。

なぜエラーが起きるようになったのか?

Airtest の adb パス決定ロジックが追加されたから

adb will now give priority to using the current adb process, or the system variable is set to adb in ANDROID_HOME. If neither is found, adb in airtest will be used.It also supports directly specifying adb_path, for example:

from airtest.core.android.android import Android, ADB
adb = ADB(adb_path=r"D:\adb\adb.exe")
dev = Android(serialno="5TSSMVBYUSEQNRY5", adb_path=r"D:\test\adb41\adb.exe")

Release v1.3.1 · AirtestProject/Airtest · GitHub

v1.3.1 から以下の優先度で adb パスが自動的に決定されるようになりました。

  1. 現在動いている adb プロセス のパス
  2. 環境変数 ANDROID_HOME で指定されているディレクトリ内の adb のパス
  3. Airtest に内蔵されている adb のパス

また、手動で adb パスを与えることもできます。

どうやらこのロジックが Device Farm の adb ラッパーの仕組みとうまく噛み合わず、エラーが起きるようになってしまったようです。

Airtest の adb パス決定ロジックを詳しく調査する

まず、Device Farm の adb ラッパーの仕組みについて見てみます。Device Farm 上で which adb を実行するとこのように返ってきます。

[DeviceFarm] which adb
/opt/dev/android-sdk-linux/platform-tools/adb

ちなみに、Device Farm では $ANDROID_HOME は /opt/dev/android-sdk-linux で定義されています。

また、Device Farm では adb に関して、以下の2プロセスが動いています。

{'exe': '/bin/bash', 'name': 'adb'}
{'exe': '/opt/dev/android-sdk-linux/platform-tools/adb.orig', 'name': 'adb.orig'}

adb は Device Farm 側で用意された adb のラッパースクリプト、 adb.orig はオリジナルの adb バイナリファイルです。 adb/bin/bash が実行していますが、実体は /opt/dev/android-sdk-linux/platform-tools/ に存在しています。

次に、Airtest の adb パスを探しに行くコードを見てみます。

@staticmethod
def get_adb_path():
    if platform.system() == "Windows":
        ADB_NAME = "adb.exe"
    else:
        ADB_NAME = "adb"
 
    # Check if adb process is already running
    for process in psutil.process_iter(['name', 'exe']):
        if process.info['name'] == ADB_NAME:
            return process.info['exe']

Airtest/airtest/core/android/adb.py at v1.3.1 · AirtestProject/Airtest · GitHub

このロジックだと name が adb のプロセスを探しに行きますが、Device Farm 上の adb スクリプトは bash のラッパースクリプトのため、psutil で取得される実行コマンドのパスは /bin/bash となってしまいます。 しかし、そのパスには adb の実体が存在しないので、Device Farm 上の Airtest では adb をうまく認識できない、という結果になっていました。 ADB_NAME = "adb.orig" とすることで adb は実行できるようになると思いますが、得られる adb パスは オリジナルの adb.orig ファイルを示すのでラッパースクリプトを実行することができません。また、 Airtest のソースコードにパッチを当てることになり保守性が高くなってしまうため、それは選択しませんでした。

Airtest 上でエラーが起きている箇所はどこか?

エラーは、 airtest.core.api の init_device を呼び出したときに発生します。

def init_device(platform="Android", uuid=None, **kwargs):
    """
    Initialize device if not yet, and set as current device.
 
    :param platform: Android, IOS or Windows
    :param uuid: uuid for target device, e.g. serialno for Android, handle for Windows, uuid for iOS
    :param kwargs: Optional platform specific keyword args, e.g. `cap_method=JAVACAP` for Android
    :return: device instance
    :Example:
 
        >>> init_device(platform="Android",uuid="SJE5T17B17", cap_method="JAVACAP")
        >>> init_device(platform="Windows",uuid="123456")
    """
    cls = import_device_cls(platform)
    dev = cls(uuid, **kwargs)
    # Add device instance in G and set as current device.
    G.add_device(dev)
    return dev

Airtest/airtest/core/api.py at v1.3.1 · AirtestProject/Airtest · GitHub

init_device は 自動テストの初期設定を行う auto_setup や デバイスの接続を行う connect_device に device の uri を指定したときに呼び出されます。

uri は "Android:///""Android:///SJE5T17B17?cap_method=javacap&touch_method=adb" のような形で表現されており、デバイスの OS と OS 固有のパラメータを入力するために指定します。

init_device では、各 OS ごとのクラスからインスタンスが生成されます。 今回の場合、Android デバイスを使っているので、 Android クラスのインスタンスが生成されることになります。

次のコードは Android クラスの init の一部です。

class Android(Device):
    """Android Device Class"""
 
    def __init__(self, serialno=None, host=None,
                 cap_method=CAP_METHOD.MINICAP,
                 touch_method=TOUCH_METHOD.MINITOUCH,
                 ime_method=IME_METHOD.YOSEMITEIME,
                 ori_method=ORI_METHOD.MINICAP,
                 display_id=None,
                 input_event=None,
                 adb_path=None,
                 name=None):
        super(Android, self).__init__()
        self.serialno = serialno or self.get_default_device()
        self._uuid = name or self.serialno
        self._cap_method = cap_method.upper()
        self._touch_method = touch_method.upper()
        self.ime_method = ime_method.upper()
        self.ori_method = ori_method.upper()
        self.display_id = display_id
        self.input_event = input_event
        # init adb
        self.adb = ADB(self.serialno, adb_path=adb_path, server_addr=host, display_id=self.display_id, input_event=self.input_event)
        self.adb.wait_for_device()

Airtest/airtest/core/android/android.py at v1.3.1 · AirtestProject/Airtest · GitHub

そして、エラーが発生している箇所は self.serialno = serialno or self.get_default_device() の部分です。 今回、接続しているデバイスは1デバイスのみでしたので、初期設定に渡す uri は "Android:///" だけ設定していました。 そのため、serialno は None であり、デバイスを特定するために get_default_device が呼ばれていました。

def get_default_device(self, adb_path=None):
    """
    Get local default device when no serialno
 
    Returns:
        local device serialno
 
    """
    if not ADB(adb_path=adb_path).devices(state="device"):
        raise IndexError("ADB devices not found")
    return ADB(adb_path=adb_path).devices(state="device")[0][0]

Airtest/airtest/core/android/android.py at v1.3.1 · AirtestProject/Airtest · GitHub

ここでは、要は adb devices を実行し、マシンに接続されているデバイス一覧を取得し、その一番上のデバイスを接続先として指定しています。 ここで問題なのは、 get_default_device の呼び出し元で adb_path を指定しないまま実行していることです。

ADB クラスに adb_path = None を渡すとどうなるか?

このコードは ADB クラスの init の一部です。

class ADB(object):
    """adb client object class"""
 
    _instances = []
    status_device = "device"
    status_offline = "offline"
    SHELL_ENCODING = "utf-8"
 
    def __init__(self, serialno=None, adb_path=None, server_addr=None, display_id=None, input_event=None):
        self.serialno = serialno
        self.adb_path = adb_path or self.get_adb_path()
        self.display_id = display_id

Airtest/airtest/core/android/adb.py at v1.3.1 · AirtestProject/Airtest · GitHub

adb_pash = None だった場合は、 self.get_adb_path() が実行されます。 そして、この関数内で、「すでに動作している adb プロセスの実行パス」である /bin/bash が取得されてしまいます。

ここで、冒頭の「Airtest の adb パス決定ロジック」に戻ります。

@staticmethod
def get_adb_path():
    if platform.system() == "Windows":
        ADB_NAME = "adb.exe"
    else:
        ADB_NAME = "adb"
 
    # Check if adb process is already running
    for process in psutil.process_iter(['name', 'exe']):
        if process.info['name'] == ADB_NAME:
            return process.info['exe']

今回のアップデートで adb_path を外から入力し指定できるようになっていますが、デバイスの初期設定のタイミング(init_device)で serialno = None だった場合は、 adb_path を指定することができません。

解決策

デバイス初期化時の uri を正確に書く

auto_setupconnect_device に対して、 "Android:///" だけを渡していたことが今回の一番の問題でした。 serialno さえあれば、接続するデバイスは一意に特定できるのです。更に、uri に adb_path パラメータを含めれば、Android クラスに adb_path を渡すことができるので、 Android クラスを介して呼び出される adb は指定した adb となります。つまり、以下のような uri にすることで Device Farm の adb_path を指定して Android デバイスに接続することができます。

auto_setup(f"Android:///{serial_no}?adb_path={adb_path}")

肝心の serial_noadb_path は テスト環境を構築するための testspec.yaml(Device Farm の環境構築ファイル) で adb deviceswhich adb を使えば取得可能なので、それを環境変数かなにかで設定しておけばホストマシンからテストスクリプトである Python 側に渡すことができます。

v1.3.1 のアップデートで不要となった処理

Airtest に内蔵されている adb をホストマシンのものに差し替える対応

冒頭でも述べましたが、 v1.3.1 以前では、 Airtest に含まれている adb とシステム側の adb が競合した場合は差し替える対応を行う必要でした。 しかし、今回から任意の adb を用いる場合は adb_path を指定する方式に変更されたので、差し替え対応が不要となりました。

従って、差し替えを行っているコードを削除しても正常に動作することが確認できました。

まとめ

adb 周りのアップデートがあった Airtest v1.3.1 へのアップデートを試みましたが、 Device Farm の adb ラッパーの仕組みと競合した結果、ADB Error が発生しました。Airtest のアップデートにより追加された adb パスを良い感じに探す仕組みでは、 Device Farm の adb.orig を動かすプロセスを探しきれていませんでした。その問題は、Airtest のデバイス初期化時に Device Farm のデバイスの serial_no と、adb プロセスの adb_path を指定することで解決しました。そうすることで、Airtest は接続しているデバイスを始めから認識できるようになり、「デバイスを探す」動作と、そのための「adb パスを探す」動作を行う必要がなくなります。

今後も自動テストの取り組みは続けていくので、興味深い内容があれば記事にしたいと思います。この記事がゲームアプリケーションのテスト自動化に取り組まれる方の助けになれば嬉しいです。

明日は Koki Watanabe さんの記事です。お楽しみに!

参考資料

システムの全体像が分かる資料

speakerdeck.com

システムの詳細が分かる資料

hackerslab.aktsk.jp

hackerslab.aktsk.jp

hackerslab.aktsk.jp