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