Add a flag for decommissioning functionality PiperOrigin-RevId: 580450009
diff --git a/ui_automator/ui_automator.py b/ui_automator/ui_automator.py index a9edc5b..36bf327 100644 --- a/ui_automator/ui_automator.py +++ b/ui_automator/ui_automator.py
@@ -21,7 +21,7 @@ import logging import os import re -from typing import Any, Callable, Sequence +from typing import Any, Callable from absl import app from absl import flags @@ -48,13 +48,18 @@ '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, ) +_DECOMMISSION = flags.DEFINE_string( + name='decommission', + default='', + help='Use --decommission {DeviceName} to remove the device on GHA.', +) + def _validate_commssioning_arg( device_name: str, pairing_code: str, gha_room: str @@ -266,6 +271,35 @@ f' device({self._connected_device.device_info["serial"]}).' ) from e + @get_android_device_ready + def decommission_device(self, device_name: str): + """Removes given device through installed apk `mbs` on Google Home App. + + Args: + device_name: Display name of the device needs to be removed on GHA. + + Raises: + ValueError: When passed argument is invalid. + MoblySnippetError: When running `removeDevice` method in snippet apk + encountered an error. + """ + if not re.fullmatch(r'\S{1,24}', device_name): + raise ValueError( + 'Value of DeviceName is invalid. Device name should be no more than' + ' 24 characters.' + ) + + self._logger.info('Start removing the device.') + try: + self._connected_device.mbs.removeDevice(device_name) + self._logger.info('Successfully remove the device on GHA.') + # TODO(b/298903492): Narrow exception type. + except Exception as e: + raise errors.MoblySnippetError( + f'Unable to remove {device_name} from GHA' + f' on device({self._connected_device.device_info["serial"]}).' + ) from e + def _get_mbs_apk_path(self) -> str: return os.path.join( os.path.dirname(os.path.abspath(__file__)), @@ -275,16 +309,20 @@ ) -def _commission(ui_automator: UIAutomator) -> None: - """Commissions a device to google fabric on GHA with flag values.""" +def _process_flags(ui_automator: UIAutomator) -> None: + """Does specific action based on given flag values.""" if _COMMISSION.value: ui_automator.commission_device(*_COMMISSION.value) + if _DECOMMISSION.value: + ui_automator.decommission_device(_DECOMMISSION.value) -def _main(argv: Sequence[str]) -> None: - del argv +def _main(argv) -> None: + if argv and len(argv) > 1: + raise app.UsageError(f'Too many command-line arguments: {argv!r}') + ui_automator = UIAutomator() - _commission(ui_automator) + _process_flags(ui_automator) def run():
diff --git a/ui_automator/ui_automator_test.py b/ui_automator/ui_automator_test.py index 4de7691..10f5c15 100644 --- a/ui_automator/ui_automator_test.py +++ b/ui_automator/ui_automator_test.py
@@ -19,6 +19,7 @@ import unittest from unittest import mock +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 @@ -38,18 +39,18 @@ '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 = _PYTHON_PATH.removesuffix('python') +_FAKE_VALID_SYS_ARGV_FOR_COMMISSIONING = [ _PYTHON_BIN_PATH + 'ui-automator', '--commission', 'm5stack,34970112332,Office', ] -_FAKE_SYS_ARGV_WITH_INVALID_PAIRING_CODE = [ +_FAKE_VALID_SYS_ARGV_FOR_DECOMMISSIONING = [ _PYTHON_BIN_PATH + 'ui-automator', - '--commission', - 'm5stack,3497,Office', + '--decommission', + 'm5stack', ] -_FAKE_SYS_ARGV_WITH_INVALID_LENGTH = [ +_FAKE_SYS_ARGV_FOR_COMMISSIONING_WITH_INVALID_LENGTH = [ _PYTHON_BIN_PATH + 'ui-automator', '--commission', '', @@ -65,6 +66,7 @@ self.mock_android_device = mock.patch.object( android_device, 'AndroidDevice' ).start() + self.addCleanup(mock.patch.stopall) @mock.patch.object(android_device, 'get_all_instances', autospec=True) def test_load_device_raises_an_error_when_adb_not_installed( @@ -254,8 +256,8 @@ ) mock_dirname.assert_called_once() - @mock.patch.object(ui_automator.UIAutomator, 'load_device') - @mock.patch.object(ui_automator.UIAutomator, 'load_snippet') + @mock.patch.object(ui_automator.UIAutomator, 'load_device', autospec=True) + @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True) def test_get_android_device_ready_raises_an_error_when_load_device_throws_an_error( self, mock_load_snippet, mock_load_device ): @@ -276,8 +278,8 @@ 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') + @mock.patch.object(ui_automator.UIAutomator, 'load_device', autospec=True) + @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True) def test_get_android_device_ready_raises_an_error_when_load_snippet_throws_an_error( self, mock_load_snippet, mock_load_device ): @@ -298,8 +300,8 @@ 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') + @mock.patch.object(ui_automator.UIAutomator, 'load_device', autospec=True) + @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True) def test_get_android_device_ready_raises_no_error_on_success( self, mock_load_snippet, mock_load_device ): @@ -313,8 +315,8 @@ 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') + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + @mock.patch.object(ui_automator.UIAutomator, 'load_snippet', autospec=True) def test_commission_device_raises_an_error_when_device_is_not_ready( self, mock_load_snippet, mock_get_all_instances ): @@ -329,7 +331,7 @@ _FAKE_MATTER_DEVICE_NAME, _FAKE_PAIRING_CODE, _FAKE_GHA_ROOM ) - @mock.patch.object(android_device, 'get_all_instances') + @mock.patch.object(android_device, 'get_all_instances', autospec=True) def test_commission_device_calls_a_method_in_snippet_with_desired_args( self, mock_get_all_instances ): @@ -351,7 +353,7 @@ _GOOGLE_HOME_APP, expected_matter_device ) - @mock.patch.object(android_device, 'get_all_instances') + @mock.patch.object(android_device, 'get_all_instances', autospec=True) def test_commission_device_fails_when_snippet_method_throws_an_error( self, mock_get_all_instances ): @@ -360,21 +362,20 @@ 'Unable to continue automated commissioning process on' f' device({self.mock_android_device.device_info["serial"]}).' ) + self.mock_android_device.mbs.commissionDevice.side_effect = Exception( + 'Can not find next button in the page.' + ) - 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, ) - 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') + @mock.patch.object(android_device, 'get_all_instances', autospec=True) def test_commission_device_raises_an_error_when_device_name_exceeds_limit( self, mock_get_all_instances ): @@ -392,7 +393,7 @@ _FAKE_GHA_ROOM, ) - @mock.patch.object(android_device, 'get_all_instances') + @mock.patch.object(android_device, 'get_all_instances', autospec=True) def test_commission_device_raises_an_error_when_pairing_code_is_invalid( self, mock_get_all_instances ): @@ -410,7 +411,7 @@ gha_room=_FAKE_GHA_ROOM, ) - @mock.patch.object(android_device, 'get_all_instances') + @mock.patch.object(android_device, 'get_all_instances', autospec=True) def test_commission_device_raises_an_error_when_gha_room_is_invalid( self, mock_get_all_instances ): @@ -433,25 +434,32 @@ gha_room=invalid_gha_room, ) - @mock.patch.object(sys, 'exit') - @mock.patch.object(ui_automator.UIAutomator, 'commission_device') + @flagsaver.flagsaver( + (ui_automator._COMMISSION, ['m5stack', '34970112332', 'Office']) + ) + @mock.patch.object(sys, 'exit', autospec=True) + @mock.patch.object( + ui_automator.UIAutomator, 'commission_device', autospec=True + ) def test_run_calls_commission_device_with_valid_arguments( self, mock_commission_device, mock_exit ): - with mock.patch.object(sys, 'argv', _FAKE_VALID_SYS_ARGV): + with mock.patch.object(sys, 'argv', _FAKE_VALID_SYS_ARGV_FOR_COMMISSIONING): ui_automator.run() mock_commission_device.assert_called_once_with( - 'm5stack', '34970112332', 'Office' + mock.ANY, 'm5stack', '34970112332', 'Office' ) mock_exit.assert_called() - @mock.patch.object(sys.stderr, 'write') - @mock.patch.object(sys, 'exit') + @mock.patch.object(sys.stderr, 'write', autospec=True) + @mock.patch.object(sys, 'exit', autospec=True) 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): + with mock.patch.object( + sys, 'argv', _FAKE_SYS_ARGV_FOR_COMMISSIONING_WITH_INVALID_LENGTH + ): ui_automator.run() self.assertEqual(mock_stderr_write.call_count, 2) @@ -470,6 +478,72 @@ self.mock_android_device.mbs.commissionDevice.assert_not_called() mock_exit.assert_called() + @flagsaver.flagsaver((ui_automator._DECOMMISSION, 'm5stack')) + @mock.patch.object( + ui_automator.UIAutomator, 'commission_device', autospec=True + ) + @mock.patch.object(sys, 'exit', autospec=True) + @mock.patch.object( + ui_automator.UIAutomator, 'decommission_device', autospec=True + ) + def test_run_calls_decommission_device_with_valid_arguments( + self, mock_decommission_device, mock_exit, mock_commission_device + ): + with mock.patch.object( + sys, 'argv', _FAKE_VALID_SYS_ARGV_FOR_DECOMMISSIONING + ): + ui_automator.run() + + mock_commission_device.assert_not_called() + mock_decommission_device.assert_called_once_with(mock.ANY, 'm5stack') + mock_exit.assert_called() + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_decommission_device_raises_an_error_with_invalid_device_name( + self, mock_get_all_instances + ): + mock_get_all_instances.return_value = [self.mock_android_device] + expected_error_message = ( + 'Value of DeviceName is invalid. Device name should be no more than 24' + ' characters.' + ) + with self.assertRaisesRegex(ValueError, expected_error_message): + self.ui_automator.decommission_device('device_name_longer_than_24_chars') + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_decommission_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 = ( + f'Unable to remove {_FAKE_MATTER_DEVICE_NAME} from GHA' + f' on device({self.mock_android_device.device_info["serial"]}).' + ) + self.mock_android_device.mbs.removeDevice.side_effect = Exception( + 'Can not remove the device.' + ) + + with self.assertRaises(errors.MoblySnippetError) as exc: + self.ui_automator.decommission_device(_FAKE_MATTER_DEVICE_NAME) + + self.assertIn(expected_error_message, str(exc.exception)) + + @mock.patch.object(android_device, 'get_all_instances', autospec=True) + def test_decommission_device_successfully_removes_a_device( + self, mock_get_all_instances + ): + mock_get_all_instances.return_value = [self.mock_android_device] + + with self.assertLogs() as cm: + self.ui_automator.decommission_device(_FAKE_MATTER_DEVICE_NAME) + + self.mock_android_device.mbs.removeDevice.assert_called_once_with( + _FAKE_MATTER_DEVICE_NAME + ) + self.assertEqual( + cm.output[2], 'INFO:root:Successfully remove the device on GHA.' + ) + if __name__ == '__main__': unittest.main()