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