No public description
PiperOrigin-RevId: 569022170
diff --git a/README.md b/README.md
index 6ec8596..624e8bc 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@
### Commissioning Matter device
-1. run script `mobly-manager --commission m5stack,34970112332,Office`
+1. run script `ui-automator --commission m5stack,34970112332,Office`
- the parameters are
* desired Matter device
* pairing code of your Matter device
diff --git a/ui_automator.py b/ui_automator.py
deleted file mode 100644
index 88148f7..0000000
--- a/ui_automator.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""UI Automator controller in python layer.
-
-A python controller that can trigger mobly UI automator snippet to achieve some
-automated UI operations on Android phones.
-"""
-import logging
-import os
-
-from mobly import utils
-from mobly.controllers import android_device
-from mobly.controllers.android_device_lib import adb
-from mobly.snippet import errors as snippet_errors
-
-from ui_automator import errors
-
-_MOBLY_SNIPPET_APK: str = 'com.chip.interop.moblysnippet'
-_MOBLY_SNIPPET_APK_NAME: str = 'mbs'
-
-
-class UIAutomator:
- """UI Automator controller in python layer."""
-
- def __init__(self, logger: logging.Logger = logging.getLogger()):
- """Inits UIAutomator.
-
- Interacts with android device by pre-installed apk. Can perform
- methods provided by a snippet installed on the android device.
-
- Args:
- logger: Injected logger, if None, specify the default(root) one.
- """
- self._logger = logger
- self._connected_device: android_device.AndroidDevice | None = None
-
- def load_device(self):
- """Selects the first connected android device.
-
- Raises:
- NoAndroidDeviceError: When no device is connected.
- """
- try:
- android_devices = android_device.get_all_instances()
- except adb.AdbError as exc:
- raise errors.AdbError(
- 'Please install adb and add it to your PATH environment variable.'
- ) from exc
-
- if not android_devices:
- raise errors.NoAndroidDeviceError(
- 'No Android device connected to the host computer.'
- )
- self._connected_device = android_devices[0]
- self._logger.info(f'connected device: [{self._connected_device}]')
-
- def load_snippet(self):
- """Loads needed mobly snippet installed on android device.
-
- Raises:
- NoAndroidDeviceError: When no device is connected.
- AndroidDeviceNotReadyError: When required snippet apk can not be loaded
- on device.
- """
- if not self._connected_device:
- raise errors.NoAndroidDeviceError(
- 'No Android device connected to the host computer.'
- )
-
- if not self._is_apk_installed(self._connected_device, _MOBLY_SNIPPET_APK):
- self._install_apk(self._connected_device, self._get_mbs_apk_path())
-
- try:
- self._connected_device.load_snippet(
- _MOBLY_SNIPPET_APK_NAME, _MOBLY_SNIPPET_APK
- )
- except snippet_errors.ServerStartPreCheckError as exc:
- # Raises when apk not be installed or instrumented on device.
- raise errors.AndroidDeviceNotReadyError(
- f"Check device({self._connected_device.device_info['serial']}) has"
- ' installed required apk.'
- ) from exc
- except (
- snippet_errors.ServerStartError,
- snippet_errors.ProtocolError,
- ) as exc:
- raise errors.AndroidDeviceNotReadyError(
- f"Check device({self._connected_device.device_info['serial']}) can"
- ' load required apk.'
- ) from exc
- except android_device.SnippetError as e:
- # Error raises when registering a package twice or a package with
- # duplicated name. Thus, It is okay to pass here as long as the snippet
- # has been loaded.
- self._logger.debug(str(e))
-
- def _is_apk_installed(
- self, device: android_device.AndroidDevice, package_name: str
- ) -> bool:
- """Checks if given package is already installed on the Android device.
-
- Args:
- device: An AndroidDevice object.
- package_name: The Android app package name.
-
- Raises:
- adb.AdbError: When executing adb command fails.
-
- Returns:
- True if package is installed. False otherwise.
- """
- out = device.adb.shell(['pm', 'list', 'package'])
- return bool(utils.grep('^package:%s$' % package_name, out))
-
- def _install_apk(
- self, device: android_device.AndroidDevice, apk_path: str
- ) -> None:
- """Installs required apk snippet to the given Android device.
-
- Args:
- device: An AndroidDevice object.
- apk_path: The absolute file path where the apk is located.
- """
- device.adb.install(['-r', '-g', apk_path])
-
- def _get_mbs_apk_path(self) -> str:
- return os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- 'android',
- 'app',
- 'snippet-0.0.0.apk',
- )
-
-
-if __name__ == '__main__':
- UIAutomator().load_device()
diff --git a/__init__.py b/ui_automator/__init__.py
similarity index 100%
rename from __init__.py
rename to ui_automator/__init__.py
diff --git a/errors.py b/ui_automator/errors.py
similarity index 80%
rename from errors.py
rename to ui_automator/errors.py
index 56ecc3c..33ce1cc 100644
--- a/errors.py
+++ b/ui_automator/errors.py
@@ -22,3 +22,9 @@
"""Raised when adb command fails."""
err_code = 3
+
+
+class MoblySnippetError(Exception):
+ """Raised when running a method in loaded snippet throws an error."""
+
+ err_code = 4
diff --git a/ui_automator/ui_automator.py b/ui_automator/ui_automator.py
new file mode 100644
index 0000000..8f032e5
--- /dev/null
+++ b/ui_automator/ui_automator.py
@@ -0,0 +1,295 @@
+"""UI Automator controller in python layer.
+
+A python controller that can trigger mobly UI automator snippet to achieve some
+automated UI operations on Android phones.
+"""
+import functools
+import logging
+import os
+import re
+from typing import Any, Callable, Sequence
+
+from absl import app
+from absl import flags
+import inflection
+from mobly import utils
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import adb
+from mobly.snippet import errors as snippet_errors
+
+from ui_automator import errors
+
+_GOOGLE_HOME_APP = {
+ 'id': 'gha',
+ 'name': 'Google Home Application (GHA)',
+ 'minVersion': '3.1.18.1',
+ 'minMatter': '231456000',
+ 'minThread': '231456000',
+}
+_MOBLY_SNIPPET_APK: str = 'com.chip.interop.moblysnippet'
+_MOBLY_SNIPPET_APK_NAME: str = 'mbs'
+_COMMISSIONING_FLAG_USAGE_GUIDE = (
+ 'Use --commission {DeviceName},{PairingCode},{GHARoom} to commission a'
+ ' device to google fabric on GHA.'
+)
+
+_COMMISSION = flags.DEFINE_list(
+ name='commission',
+ default=None,
+ help=_COMMISSIONING_FLAG_USAGE_GUIDE,
+)
+
+
+def _validate_commssioning_arg(
+ device_name: str, pairing_code: str, gha_room: str
+) -> None:
+ """Returns None if commissioning values are valid.
+
+ Args:
+ device_name: Display name of commissioned device on GHA.
+ pairing_code: An 11-digit or 21-digit numeric code which contains the
+ information needed to commission a matter device.
+ gha_room: Assigned room of commissioned device on GHA.
+
+ Raises:
+ ValueError: When any of passed arguments is invalid.
+ """
+ device_name_match = re.fullmatch(r'\S{1,24}', device_name)
+ pairing_code_match = re.fullmatch(r'^(\d{11}|\d{21})$', pairing_code)
+ gha_room_match = re.fullmatch(
+ r'Attic|Back door|Backyard|Basement|Bathroom|Bedroom|Den|Dining'
+ r' Room|Entryway|Family Room|Front door|Front'
+ r' Yard|Garage|Hallway|Kitchen|Living Room|Master'
+ r' Bedroom|Office|Shed|Side door',
+ gha_room,
+ )
+
+ if not device_name_match:
+ raise ValueError(
+ 'Value of DeviceName is invalid. Device name should be no more than 24'
+ ' characters.'
+ )
+ if not pairing_code_match:
+ raise ValueError(
+ 'Value of PairingCode is invalid. Paring code should be 11-digit or'
+ ' 21-digit numeric code.'
+ )
+ if not gha_room_match:
+ raise ValueError(
+ 'Value of GHARoom is invalid. Valid values for GHARoom: Attic|Back'
+ ' door|Backyard|Basement|Bathroom|Bedroom|Den|Dining'
+ ' Room|Entryway|Family Room|Front door|Front'
+ ' Yard|Garage|Hallway|Kitchen|Living Room|Master'
+ ' Bedroom|Office|Shed|Side door'
+ )
+
+
+flags.register_validator(
+ 'commission',
+ lambda value: value and len(value) == 3,
+ message=_COMMISSIONING_FLAG_USAGE_GUIDE,
+)
+
+
+def get_android_device_ready(func: Callable[..., Any]) -> Callable[..., Any]:
+ """A decorator to check if an Android device can be controlled by installed apk.
+
+ Args:
+ func: The function to be wrapped.
+
+ Returns:
+ The wrapped function.
+
+ Raises:
+ NoAndroidDeviceError: When no device is connected.
+ AndroidDeviceNotReadyError: When required snippet apk can not be loaded
+ on device.
+ """
+
+ @functools.wraps(func)
+ def wrapped_func(self, *args, **kwargs):
+ try:
+ self.load_device()
+ self.load_snippet()
+ except (
+ errors.AndroidDeviceNotReadyError,
+ errors.NoAndroidDeviceError,
+ ) as e:
+ raise errors.AndroidDeviceNotReadyError(f'{func.__name__} failed.') from e
+
+ return func(self, *args, **kwargs)
+
+ return wrapped_func
+
+
+class UIAutomator:
+ """UI Automator controller in python layer."""
+
+ def __init__(self, logger: logging.Logger = logging.getLogger()):
+ """Inits UIAutomator.
+
+ Interacts with android device by pre-installed apk. Can perform
+ methods provided by a snippet installed on the android device.
+
+ Args:
+ logger: Injected logger, if None, specify the default(root) one.
+ """
+ self._logger = logger
+ self._connected_device: android_device.AndroidDevice | None = None
+
+ def load_device(self):
+ """Selects the first connected android device.
+
+ Raises:
+ NoAndroidDeviceError: When no device is connected.
+ """
+ try:
+ android_devices = android_device.get_all_instances()
+ except adb.AdbError as exc:
+ raise errors.AdbError(
+ 'Please install adb and add it to your PATH environment variable.'
+ ) from exc
+
+ if not android_devices:
+ raise errors.NoAndroidDeviceError(
+ 'No Android device connected to the host computer.'
+ )
+ self._connected_device = android_devices[0]
+ self._logger.info(f'connected device: [{self._connected_device}]')
+
+ def load_snippet(self):
+ """Loads needed mobly snippet installed on android device.
+
+ Raises:
+ NoAndroidDeviceError: When no device is connected.
+ AndroidDeviceNotReadyError: When required snippet apk can not be loaded
+ on device.
+ """
+ if not self._connected_device:
+ raise errors.NoAndroidDeviceError(
+ 'No Android device connected to the host computer.'
+ )
+
+ if not self._is_apk_installed(self._connected_device, _MOBLY_SNIPPET_APK):
+ self._install_apk(self._connected_device, self._get_mbs_apk_path())
+
+ try:
+ self._connected_device.load_snippet(
+ _MOBLY_SNIPPET_APK_NAME, _MOBLY_SNIPPET_APK
+ )
+ except snippet_errors.ServerStartPreCheckError as exc:
+ # Raises when apk not be installed or instrumented on device.
+ raise errors.AndroidDeviceNotReadyError(
+ f"Check device({self._connected_device.device_info['serial']}) has"
+ ' installed required apk.'
+ ) from exc
+ except (
+ snippet_errors.ServerStartError,
+ snippet_errors.ProtocolError,
+ ) as exc:
+ raise errors.AndroidDeviceNotReadyError(
+ f"Check device({self._connected_device.device_info['serial']}) can"
+ ' load required apk.'
+ ) from exc
+ except android_device.SnippetError as e:
+ # Error raises when registering a package twice or a package with
+ # duplicated name. Thus, It is okay to pass here as long as the snippet
+ # has been loaded.
+ self._logger.debug(str(e))
+
+ @get_android_device_ready
+ def commission_device(
+ self,
+ device_name: str,
+ pairing_code: str,
+ gha_room: str
+ ):
+ """Commissions a device through installed apk `mbs` on Google Home App.
+
+ Args:
+ device_name: Display name of commissioned device on GHA.
+ pairing_code: An 11-digit or 21-digit numeric code which contains the
+ information needed to commission a matter device.
+ gha_room: Assigned room of commissioned device on GHA.
+
+ Raises:
+ ValueError: When any of passed arguments is invalid.
+ MoblySnippetError: When running `commissionDevice` method in snippet apk
+ encountered an error.
+ """
+ self._logger.info('Start commissioning the device.')
+
+ _validate_commssioning_arg(device_name, pairing_code, gha_room)
+
+ device_name_snake_case = f'_{inflection.underscore(device_name)}'
+ matter_device = {
+ 'id': device_name_snake_case,
+ 'name': device_name,
+ 'pairingCode': pairing_code,
+ 'roomName': gha_room,
+ }
+
+ try:
+ self._connected_device.mbs.commissionDevice(
+ _GOOGLE_HOME_APP, matter_device
+ )
+ self._logger.info('Successfully commission the device on GHA.')
+ # TODO(b/298903492): Narrow exception type.
+ except Exception as e:
+ raise errors.MoblySnippetError(
+ 'Unable to continue automated commissioning process on'
+ f' device({self._connected_device.device_info["serial"]}).'
+ ) from e
+
+ def _is_apk_installed(
+ self, device: android_device.AndroidDevice, package_name: str
+ ) -> bool:
+ """Checks if given package is already installed on the Android device.
+
+ Args:
+ device: An AndroidDevice object.
+ package_name: The Android app package name.
+
+ Raises:
+ adb.AdbError: When executing adb command fails.
+
+ Returns:
+ True if package is installed. False otherwise.
+ """
+ out = device.adb.shell(['pm', 'list', 'package'])
+ return bool(utils.grep('^package:%s$' % package_name, out))
+
+ def _install_apk(
+ self, device: android_device.AndroidDevice, apk_path: str
+ ) -> None:
+ """Installs required apk snippet to the given Android device.
+
+ Args:
+ device: An AndroidDevice object.
+ apk_path: The absolute file path where the apk is located.
+ """
+ device.adb.install(['-r', '-g', apk_path])
+
+ def _get_mbs_apk_path(self) -> str:
+ return os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ 'android',
+ 'app',
+ 'snippet-0.0.0.apk',
+ )
+
+
+def _commission(ui_automator: UIAutomator) -> None:
+ """Commissions a device to google fabric on GHA with flag values."""
+ if _COMMISSION.value:
+ ui_automator.commission_device(*_COMMISSION.value)
+
+
+def main(argv: Sequence[str]) -> None:
+ del argv
+ ui_automator = UIAutomator()
+ _commission(ui_automator)
+
+
+if __name__ == '__main__':
+ app.run(main)
diff --git a/ui_automator/ui_automator_test.py b/ui_automator/ui_automator_test.py
new file mode 100644
index 0000000..829b7d3
--- /dev/null
+++ b/ui_automator/ui_automator_test.py
@@ -0,0 +1,432 @@
+"""Unittest Lab exercise to test implementation of "Synonym Dictionary"."""
+import os
+import subprocess
+import sys
+import traceback
+import unittest
+from unittest import mock
+
+from absl import app
+from absl.testing import flagsaver
+from mobly.controllers import android_device
+from mobly.controllers.android_device_lib import adb
+from mobly.snippet import errors as snippet_errors
+
+from ui_automator import errors
+from ui_automator import ui_automator
+
+_FAKE_MATTER_DEVICE_NAME = 'fake-matter-device-name'
+_FAKE_GHA_ROOM = 'Office'
+_FAKE_PAIRING_CODE = '34970112332'
+_GOOGLE_HOME_APP = {
+ 'id': 'gha',
+ 'name': 'Google Home Application (GHA)',
+ 'minVersion': '3.1.18.1',
+ 'minMatter': '231456000',
+ 'minThread': '231456000',
+}
+_PYTHON_PATH = subprocess.check_output(['which', 'python']).decode('utf-8')
+_PYTHON_BIN_PATH = _PYTHON_PATH[:-7]
+_FAKE_VALID_SYS_ARGV = [
+ _PYTHON_BIN_PATH + 'ui-automator',
+ '--commission',
+ 'm5stack,34970112332,Office',
+]
+_FAKE_SYS_ARGV_WITH_INVALID_PAIRING_CODE = [
+ _PYTHON_BIN_PATH + 'ui-automator',
+ '--commission',
+ 'm5stack,3497,Office',
+]
+_FAKE_SYS_ARGV_WITH_INVALID_LENGTH = [
+ _PYTHON_BIN_PATH + 'ui-automator',
+ '--commission',
+ '',
+]
+
+
+class UIAutomatorTest(unittest.TestCase):
+
+ def setUp(self):
+ """This method will be run before each of the test methods in the class."""
+ super().setUp()
+ self.ui_automator = ui_automator.UIAutomator()
+ self.mock_android_device = mock.patch.object(
+ android_device, 'AndroidDevice'
+ ).start()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ def test_load_device_raises_an_error_when_adb_not_installed(
+ self, mock_get_all_instances
+ ):
+ mock_get_all_instances.side_effect = adb.AdbError(
+ cmd='adb devices',
+ stdout='fake_msg',
+ stderr='adb command not found',
+ ret_code=1,
+ )
+ with self.assertRaisesRegex(
+ errors.AdbError,
+ r'Please install adb and add it to your PATH environment variable\.',
+ ):
+ self.ui_automator.load_device()
+
+ @mock.patch.object(
+ android_device, 'get_all_instances', autospec=True, return_value=[]
+ )
+ def test_load_device_no_android_device_error(
+ self, unused_mock_get_all_instances
+ ):
+ with self.assertRaises(errors.NoAndroidDeviceError):
+ self.ui_automator.load_device()
+
+ @mock.patch.object(
+ android_device, 'get_all_instances', autospec=True, return_value=[1, 2]
+ )
+ def test_load_device_success(self, unused_mock_get_all_instances):
+ with self.assertLogs() as cm:
+ self.ui_automator.load_device()
+ self.assertEqual(cm.output, ['INFO:root:connected device: [1]'])
+
+ def test_load_snippet_without_load_device_raises_error(self):
+ with self.assertRaises(errors.NoAndroidDeviceError):
+ self.ui_automator.load_snippet()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ def test_load_snippet_success(self, mock_get_all_instances):
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ self.ui_automator.load_device()
+ self.ui_automator.load_snippet()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ def test_load_snippet_fails_with_server_start_pre_check_error(
+ self, mock_get_all_instances
+ ):
+ self.mock_android_device.load_snippet.side_effect = (
+ snippet_errors.ServerStartPreCheckError('fake_ad', 'fake_msg')
+ )
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ self.ui_automator.load_device()
+
+ with self.assertRaises(errors.AndroidDeviceNotReadyError):
+ self.ui_automator.load_snippet()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ def test_load_snippet_fails_with_server_start_error(
+ self, mock_get_all_instances
+ ):
+ self.mock_android_device.load_snippet.side_effect = (
+ snippet_errors.ServerStartError('fake_ad', 'fake_msg')
+ )
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ self.ui_automator.load_device()
+
+ with self.assertRaises(errors.AndroidDeviceNotReadyError):
+ self.ui_automator.load_snippet()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ def test_load_snippet_fails_with_protocol_error(self, mock_get_all_instances):
+ self.mock_android_device.load_snippet.side_effect = (
+ snippet_errors.ProtocolError('fake_ad', 'fake_msg')
+ )
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ self.ui_automator.load_device()
+
+ with self.assertRaises(errors.AndroidDeviceNotReadyError):
+ self.ui_automator.load_snippet()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ def test_load_snippet_fails_with_snippet_error(self, mock_get_all_instances):
+ self.mock_android_device.load_snippet.side_effect = (
+ android_device.SnippetError('fake_device', 'fake_msg')
+ )
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ self.ui_automator.load_device()
+
+ with self.assertLogs(level='DEBUG') as cm:
+ self.ui_automator.load_snippet()
+ self.assertEqual(
+ cm.output,
+ [
+ "DEBUG:root:'fake_device'::Service<SnippetManagementService>"
+ ' fake_msg'
+ ],
+ )
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ @mock.patch.object(
+ os.path, 'dirname', autospec=True, return_value='/path/to/'
+ )
+ def test_load_snippet_installs_apk_when_apk_is_not_installed(
+ self, mock_dirname, mock_get_all_instances
+ ):
+ self.mock_android_device.adb.shell.return_value = b'package:installed.apk\n'
+ mock_get_all_instances.return_value = [self.mock_android_device]
+ self.ui_automator.load_device()
+
+ self.ui_automator.load_snippet()
+
+ self.mock_android_device.adb.install.assert_called_once_with(
+ ['-r', '-g', '/path/to/android/app/snippet-0.0.0.apk']
+ )
+ mock_dirname.assert_called_once()
+
+ @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+ @mock.patch.object(os.path, 'dirname', autospec=True)
+ def test_load_snippet_should_not_install_apk_when_apk_is_installed(
+ self, mock_dirname, mock_get_all_instances
+ ):
+ self.mock_android_device.adb.shell.return_value = (
+ b'package:com.chip.interop.moblysnippet'
+ )
+ mock_get_all_instances.return_value = [self.mock_android_device]
+ self.ui_automator.load_device()
+
+ self.ui_automator.load_snippet()
+
+ self.mock_android_device.adb.install.assert_not_called()
+ mock_dirname.assert_not_called()
+
+ @mock.patch.object(ui_automator.UIAutomator, 'load_device')
+ @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+ def test_get_android_device_ready_raises_an_error_when_load_device_throws_an_error(
+ self, mock_load_snippet, mock_load_device
+ ):
+ mock_load_device.side_effect = errors.NoAndroidDeviceError(
+ 'No Android device connected to the host computer.'
+ )
+
+ @ui_automator.get_android_device_ready
+ def decorated_function(self):
+ del self
+ pass
+
+ with self.assertRaisesRegex(
+ errors.AndroidDeviceNotReadyError, r'decorated_function failed\.'
+ ):
+ decorated_function(self.ui_automator)
+
+ mock_load_device.assert_called_once()
+ mock_load_snippet.assert_not_called()
+
+ @mock.patch.object(ui_automator.UIAutomator, 'load_device')
+ @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+ def test_get_android_device_ready_raises_an_error_when_load_snippet_throws_an_error(
+ self, mock_load_snippet, mock_load_device
+ ):
+ mock_load_snippet.side_effect = errors.AndroidDeviceNotReadyError(
+ 'Check device(fake-serial) has installed required apk.'
+ )
+
+ @ui_automator.get_android_device_ready
+ def decorated_function(self):
+ del self
+ pass
+
+ with self.assertRaisesRegex(
+ errors.AndroidDeviceNotReadyError, r'decorated_function failed\.'
+ ):
+ decorated_function(self.ui_automator)
+
+ mock_load_snippet.assert_called_once()
+ mock_load_device.assert_called_once()
+
+ @mock.patch.object(ui_automator.UIAutomator, 'load_device')
+ @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+ def test_get_android_device_ready_raises_no_error_on_success(
+ self, mock_load_snippet, mock_load_device
+ ):
+ @ui_automator.get_android_device_ready
+ def decorated_function(self):
+ del self
+ pass
+
+ decorated_function(self.ui_automator)
+
+ mock_load_snippet.assert_called_once()
+ mock_load_device.assert_called_once()
+
+ @mock.patch.object(android_device, 'get_all_instances')
+ @mock.patch.object(ui_automator.UIAutomator, 'load_snippet')
+ def test_commission_device_raises_an_error_when_device_is_not_ready(
+ self, mock_load_snippet, mock_get_all_instances
+ ):
+ mock_get_all_instances.return_value = [self.mock_android_device]
+ error_msg = 'Check device(fake-serial) is ready.'
+ mock_load_snippet.side_effect = errors.AndroidDeviceNotReadyError(error_msg)
+
+ with self.assertRaisesRegex(
+ errors.AndroidDeviceNotReadyError, 'commission_device failed.'
+ ):
+ self.ui_automator.commission_device(
+ _FAKE_MATTER_DEVICE_NAME, _FAKE_PAIRING_CODE, _FAKE_GHA_ROOM
+ )
+
+ @mock.patch.object(android_device, 'get_all_instances')
+ def test_commission_device_calls_a_method_in_snippet_with_desired_args(
+ self, mock_get_all_instances
+ ):
+ mock_get_all_instances.return_value = [self.mock_android_device]
+ expected_matter_device = {
+ 'id': '_fake_matter_device_name',
+ 'name': _FAKE_MATTER_DEVICE_NAME,
+ 'pairingCode': _FAKE_PAIRING_CODE,
+ 'roomName': _FAKE_GHA_ROOM,
+ }
+
+ self.ui_automator.commission_device(
+ _FAKE_MATTER_DEVICE_NAME,
+ _FAKE_PAIRING_CODE,
+ _FAKE_GHA_ROOM,
+ )
+
+ self.mock_android_device.mbs.commissionDevice.assert_called_once_with(
+ _GOOGLE_HOME_APP, expected_matter_device
+ )
+
+ @mock.patch.object(android_device, 'get_all_instances')
+ def test_commission_device_fails_when_snippet_method_throws_an_error(
+ self, mock_get_all_instances
+ ):
+ mock_get_all_instances.return_value = [self.mock_android_device]
+ expected_error_message = (
+ 'Unable to continue automated commissioning process on'
+ f' device({self.mock_android_device.device_info["serial"]}).'
+ )
+
+ with mock.patch.object(self.mock_android_device, 'mbs') as snippet:
+ snippet.commissionDevice.side_effect = Exception(
+ 'Can not find next button in the page.'
+ )
+ with self.assertRaises(errors.MoblySnippetError) as exc:
+ self.ui_automator.commission_device(
+ _FAKE_MATTER_DEVICE_NAME,
+ _FAKE_PAIRING_CODE,
+ _FAKE_GHA_ROOM,
+ )
+
+ self.assertIn(expected_error_message, str(exc.exception))
+
+ @mock.patch.object(android_device, 'get_all_instances')
+ def test_commission_device_raises_an_error_when_device_name_exceeds_limit(
+ self, mock_get_all_instances
+ ):
+ invalid_device_name = 'fakeDeviceNameWith25Chars'
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ with self.assertRaisesRegex(
+ ValueError,
+ 'Value of DeviceName is invalid. Device name should be no more than 24'
+ ' characters.',
+ ):
+ self.ui_automator.commission_device(
+ invalid_device_name,
+ _FAKE_PAIRING_CODE,
+ _FAKE_GHA_ROOM,
+ )
+
+ @mock.patch.object(android_device, 'get_all_instances')
+ def test_commission_device_raises_an_error_when_pairing_code_is_invalid(
+ self, mock_get_all_instances
+ ):
+ invalid_pairing_code = '123456789'
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ with self.assertRaisesRegex(
+ ValueError,
+ 'Value of PairingCode is invalid. Paring code should be 11-digit or'
+ ' 21-digit numeric code.',
+ ):
+ self.ui_automator.commission_device(
+ device_name=_FAKE_MATTER_DEVICE_NAME,
+ pairing_code=invalid_pairing_code,
+ gha_room=_FAKE_GHA_ROOM,
+ )
+
+ @mock.patch.object(android_device, 'get_all_instances')
+ def test_commission_device_raises_an_error_when_gha_room_is_invalid(
+ self, mock_get_all_instances
+ ):
+ invalid_gha_room = 'Attic 2'
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ with self.assertRaisesRegex(
+ ValueError,
+ (
+ 'Value of GHARoom is invalid. Valid values for GHARoom: Attic|Back'
+ ' door|Backyard|Basement|Bathroom|Bedroom|Den|Dining'
+ ' Room|Entryway|Family Room|Front door|Front'
+ ' Yard|Garage|Hallway|Kitchen|Living Room|Master'
+ ' Bedroom|Office|Shed|Side door'
+ ),
+ ):
+ self.ui_automator.commission_device(
+ device_name=_FAKE_MATTER_DEVICE_NAME,
+ pairing_code=_FAKE_PAIRING_CODE,
+ gha_room=invalid_gha_room,
+ )
+
+ @flagsaver.flagsaver(
+ (ui_automator._COMMISSION, ['m5stack', '34970112332', 'Office'])
+ )
+ @mock.patch.object(ui_automator.UIAutomator, 'commission_device')
+ def test_main_calls_commission_device_with_valid_arguments(
+ self, mock_commission_device
+ ):
+ ui_automator.main(_FAKE_VALID_SYS_ARGV)
+
+ mock_commission_device.assert_called_once_with(
+ 'm5stack', '34970112332', 'Office'
+ )
+
+ @flagsaver.flagsaver(
+ (ui_automator._COMMISSION, ['m5stack', '3497', 'Office'])
+ )
+ @mock.patch.object(android_device, 'get_all_instances')
+ def test_main_should_print_a_hint_when_cmd_args_have_invalid_value(
+ self,
+ mock_get_all_instances,
+ ):
+ mock_get_all_instances.return_value = [self.mock_android_device]
+
+ with self.assertRaises(ValueError) as e:
+ ui_automator.main(_FAKE_SYS_ARGV_WITH_INVALID_PAIRING_CODE)
+
+ self.assertIn(
+ 'Value of PairingCode is invalid. Paring code should be 11-digit or'
+ ' 21-digit numeric code.',
+ str(traceback.format_exception(e.exception)),
+ )
+ self.mock_android_device.mbs.commissionDevice.assert_not_called()
+
+ @mock.patch.object(sys.stderr, 'write')
+ @mock.patch.object(sys, 'exit')
+ def test_commission_with_cmd_invalid_arg_should_stderr(
+ self, mock_exit, mock_stderr_write
+ ):
+ with mock.patch.object(sys, 'argv', _FAKE_SYS_ARGV_WITH_INVALID_LENGTH):
+ app.run(ui_automator.main)
+
+ self.assertEqual(mock_stderr_write.call_count, 2)
+ first_call_args = ''.join(mock_stderr_write.call_args_list[0][0])
+ self.assertEqual(
+ first_call_args,
+ 'FATAL Flags parsing error: flag --commission=[]: Use'
+ ' --commission {DeviceName},{PairingCode},{GHARoom} to commission a'
+ ' device to google fabric on GHA.\n',
+ )
+ second_call_args = ''.join(mock_stderr_write.call_args_list[1][0])
+ self.assertEqual(
+ second_call_args,
+ 'Pass --helpshort or --helpfull to see help on flags.\n',
+ )
+ self.mock_android_device.mbs.commissionDevice.assert_not_called()
+ mock_exit.assert_called()
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/ui_automator_test.py b/ui_automator_test.py
deleted file mode 100644
index 951c80a..0000000
--- a/ui_automator_test.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""Unittest Lab exercise to test implementation of "Synonym Dictionary"."""
-import os
-import unittest
-
-from unittest import mock
-from mobly.controllers import android_device
-from mobly.controllers.android_device_lib import adb
-from mobly.snippet import errors as snippet_errors
-
-from ui_automator import errors
-from ui_automator import ui_automator
-
-
-class UIAutomatorTest(unittest.TestCase):
-
- def setUp(self):
- """This method will be run before each of the test methods in the class."""
- super().setUp()
- self.ui_automator = ui_automator.UIAutomator()
- self.mock_android_device = mock.patch.object(
- android_device, 'AndroidDevice'
- ).start()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- def test_load_device_raises_an_error_when_adb_not_installed(
- self, mock_get_all_instances
- ):
- mock_get_all_instances.side_effect = adb.AdbError(
- cmd='adb devices',
- stdout='fake_msg',
- stderr='adb command not found',
- ret_code=1,
- )
- with self.assertRaisesRegex(
- errors.AdbError,
- r'Please install adb and add it to your PATH environment variable\.',
- ):
- self.ui_automator.load_device()
-
- @mock.patch.object(
- android_device, 'get_all_instances', autospec=True, return_value=[]
- )
- def test_load_device_no_android_device_error(
- self, unused_mock_get_all_instances
- ):
- with self.assertRaises(errors.NoAndroidDeviceError):
- self.ui_automator.load_device()
-
- @mock.patch.object(
- android_device, 'get_all_instances', autospec=True, return_value=[1, 2]
- )
- def test_load_device_success(self, unused_mock_get_all_instances):
- with self.assertLogs() as cm:
- self.ui_automator.load_device()
- self.assertEqual(cm.output, ['INFO:root:connected device: [1]'])
-
- def test_load_snippet_without_load_device_raises_error(self):
- with self.assertRaises(errors.NoAndroidDeviceError):
- self.ui_automator.load_snippet()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- def test_load_snippet_success(self, mock_get_all_instances):
- mock_get_all_instances.return_value = [self.mock_android_device]
-
- self.ui_automator.load_device()
- self.ui_automator.load_snippet()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- def test_load_snippet_fails_with_server_start_pre_check_error(
- self, mock_get_all_instances
- ):
- self.mock_android_device.load_snippet.side_effect = (
- snippet_errors.ServerStartPreCheckError('fake_ad', 'fake_msg')
- )
- mock_get_all_instances.return_value = [self.mock_android_device]
-
- self.ui_automator.load_device()
-
- with self.assertRaises(errors.AndroidDeviceNotReadyError):
- self.ui_automator.load_snippet()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- def test_load_snippet_fails_with_server_start_error(
- self, mock_get_all_instances
- ):
- self.mock_android_device.load_snippet.side_effect = (
- snippet_errors.ServerStartError('fake_ad', 'fake_msg')
- )
- mock_get_all_instances.return_value = [self.mock_android_device]
-
- self.ui_automator.load_device()
-
- with self.assertRaises(errors.AndroidDeviceNotReadyError):
- self.ui_automator.load_snippet()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- def test_load_snippet_fails_with_protocol_error(self, mock_get_all_instances):
- self.mock_android_device.load_snippet.side_effect = (
- snippet_errors.ProtocolError('fake_ad', 'fake_msg')
- )
- mock_get_all_instances.return_value = [self.mock_android_device]
-
- self.ui_automator.load_device()
-
- with self.assertRaises(errors.AndroidDeviceNotReadyError):
- self.ui_automator.load_snippet()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- def test_load_snippet_fails_with_snippet_error(self, mock_get_all_instances):
- self.mock_android_device.load_snippet.side_effect = (
- android_device.SnippetError('fake_device', 'fake_msg')
- )
- mock_get_all_instances.return_value = [self.mock_android_device]
-
- self.ui_automator.load_device()
-
- with self.assertLogs(level='DEBUG') as cm:
- self.ui_automator.load_snippet()
- self.assertEqual(
- cm.output,
- [
- "DEBUG:root:'fake_device'::Service<SnippetManagementService>"
- ' fake_msg'
- ],
- )
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- @mock.patch.object(
- os.path, 'dirname', autospec=True, return_value='/path/to/'
- )
- def test_load_snippet_installs_apk_when_apk_is_not_installed(
- self, mock_dirname, mock_get_all_instances
- ):
- self.mock_android_device.adb.shell.return_value = (
- b'package:installed.apk\n'
- )
- mock_get_all_instances.return_value = [self.mock_android_device]
- self.ui_automator.load_device()
-
- self.ui_automator.load_snippet()
-
- self.mock_android_device.adb.install.assert_called_once_with(
- ['-r', '-g', '/path/to/android/app/snippet-0.0.0.apk']
- )
- mock_dirname.assert_called_once()
-
- @mock.patch.object(android_device, 'get_all_instances', autospec=True)
- @mock.patch.object(os.path, 'dirname', autospec=True)
- def test_load_snippet_should_not_install_apk_when_apk_is_installed(
- self, mock_dirname, mock_get_all_instances
- ):
- self.mock_android_device.adb.shell.return_value = (
- b'package:com.chip.interop.moblysnippet'
- )
- mock_get_all_instances.return_value = [self.mock_android_device]
- self.ui_automator.load_device()
-
- self.ui_automator.load_snippet()
-
- self.mock_android_device.adb.install.assert_not_called()
- mock_dirname.assert_not_called()
-
-
-if __name__ == '__main__':
- unittest.main()