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()