aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMinghao Li <minghaoli@google.com>2022-04-15 11:48:02 +0800
committerGitHub <noreply@github.com>2022-04-14 20:48:02 -0700
commit6d7b6210372468cc05699d53877c338029f24ea2 (patch)
treeeafeca59bc672113c4ed12adffa2c1d49f125407
parent38826e31e9319a7b93ff1b5df38ce203aa443844 (diff)
downloadmobly-6d7b6210372468cc05699d53877c338029f24ea2.tar.gz
Adding Snippet Client V2 for Android, Step 1 (#804)
-rw-r--r--mobly/controllers/android_device_lib/snippet_client_v2.py341
-rw-r--r--mobly/snippet/client_base.py6
-rw-r--r--mobly/snippet/errors.py12
-rw-r--r--tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py394
4 files changed, 751 insertions, 2 deletions
diff --git a/mobly/controllers/android_device_lib/snippet_client_v2.py b/mobly/controllers/android_device_lib/snippet_client_v2.py
new file mode 100644
index 0000000..be3b98a
--- /dev/null
+++ b/mobly/controllers/android_device_lib/snippet_client_v2.py
@@ -0,0 +1,341 @@
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Snippet Client V2 for Interacting with Snippet Server on Android Device."""
+
+import re
+
+from mobly import utils
+from mobly.controllers.android_device_lib import adb
+from mobly.controllers.android_device_lib import errors as android_device_lib_errors
+from mobly.snippet import client_base
+from mobly.snippet import errors
+
+# The package of the instrumentation runner used for mobly snippet
+_INSTRUMENTATION_RUNNER_PACKAGE = 'com.google.android.mobly.snippet.SnippetRunner'
+
+# The command template to start the snippet server
+_LAUNCH_CMD = (
+ '{shell_cmd} am instrument {user} -w -e action start {snippet_package}/'
+ f'{_INSTRUMENTATION_RUNNER_PACKAGE}')
+
+# The command template to stop the snippet server
+_STOP_CMD = ('am instrument {user} -w -e action stop {snippet_package}/'
+ f'{_INSTRUMENTATION_RUNNER_PACKAGE}')
+
+# Major version of the launch and communication protocol being used by this
+# client.
+# Incrementing this means that compatibility with clients using the older
+# version is broken. Avoid breaking compatibility unless there is no other
+# choice.
+_PROTOCOL_MAJOR_VERSION = 1
+
+# Minor version of the launch and communication protocol.
+# Increment this when new features are added to the launch and communication
+# protocol that are backwards compatible with the old protocol and don't break
+# existing clients.
+_PROTOCOL_MINOR_VERSION = 0
+
+# Test that uses UiAutomation requires the shell session to be maintained while
+# test is in progress. However, this requirement does not hold for the test that
+# deals with device disconnection (Once device disconnects, the shell session
+# that started the instrument ends, and UiAutomation fails with error:
+# "UiAutomation not connected"). To keep the shell session and redirect
+# stdin/stdout/stderr, use "setsid" or "nohup" while launching the
+# instrumentation test. Because these commands may not be available in every
+# Android system, try to use it only if at least one exists.
+_SETSID_COMMAND = 'setsid'
+
+_NOHUP_COMMAND = 'nohup'
+
+
+class SnippetClientV2(client_base.ClientBase):
+ """Snippet client V2 for interacting with snippet server on Android Device.
+
+ See base class documentation for a list of public attributes and communication
+ protocols.
+
+ For a description of the launch protocols, see the documentation in
+ mobly-snippet-lib, SnippetRunner.java.
+ """
+
+ def __init__(self, package, ad):
+ """Initializes the instance of Snippet Client V2.
+
+ Args:
+ package: str, see base class.
+ ad: AndroidDevice, the android device object associated with this client.
+ """
+ super().__init__(package=package, device=ad)
+ self._adb = ad.adb
+ self._user_id = None
+ self._proc = None
+
+ @property
+ def user_id(self):
+ """The user id to use for this snippet client.
+
+ All the operations of the snippet client should be used for a particular
+ user. For more details, see the Android documentation of testing
+ multiple users.
+
+ Thus this value is cached and, once set, does not change through the
+ lifecycles of this snippet client object. This caching also reduces the
+ number of adb calls needed.
+
+ Although for now self._user_id won't be modified once set, we use
+ `property` to avoid issuing adb commands in the constructor.
+
+ Returns:
+ An integer of the user id.
+ """
+ if self._user_id is None:
+ self._user_id = self._adb.current_user_id
+ return self._user_id
+
+ def before_starting_server(self):
+ """Performs the preparation steps before starting the remote server.
+
+ This function performs following preparation steps:
+ * Validate that the Mobly Snippet app is available on the device.
+ * Disable hidden api blocklist if necessary and possible.
+
+ Raises:
+ errors.ServerStartPreCheckError: if the server app is not installed
+ for the current user.
+ """
+ self._validate_snippet_app_on_device()
+ self._disable_hidden_api_blocklist()
+
+ def _validate_snippet_app_on_device(self):
+ """Validates the Mobly Snippet app is available on the device.
+
+ To run as an instrumentation test, the Mobly Snippet app must already be
+ installed and instrumented on the Android device.
+
+ Raises:
+ errors.ServerStartPreCheckError: if the server app is not installed
+ for the current user.
+ """
+ # Validate that the Mobly Snippet app is installed for the current user.
+ out = self._adb.shell(f'pm list package --user {self.user_id}')
+ if not utils.grep(f'^package:{self.package}$', out):
+ raise errors.ServerStartPreCheckError(
+ self._device,
+ f'{self.package} is not installed for user {self.user_id}.')
+
+ # Validate that the app is instrumented.
+ out = self._adb.shell('pm list instrumentation')
+ matched_out = utils.grep(
+ f'^instrumentation:{self.package}/{_INSTRUMENTATION_RUNNER_PACKAGE}',
+ out)
+ if not matched_out:
+ raise errors.ServerStartPreCheckError(
+ self._device,
+ f'{self.package} is installed, but it is not instrumented.')
+ match = re.search(r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$',
+ matched_out[0])
+ target_name = match.group(3)
+ # Validate that the instrumentation target is installed if it's not the
+ # same as the snippet package.
+ if target_name != self.package:
+ out = self._adb.shell(f'pm list package --user {self.user_id}')
+ if not utils.grep(f'^package:{target_name}$', out):
+ raise errors.ServerStartPreCheckError(
+ self._device,
+ f'Instrumentation target {target_name} is not installed for user '
+ f'{self.user_id}.')
+
+ def _disable_hidden_api_blocklist(self):
+ """If necessary and possible, disables hidden api blocklist."""
+ sdk_version = int(self._device.build_info['build_version_sdk'])
+ if self._device.is_rootable and sdk_version >= 28:
+ self._device.adb.shell(
+ 'settings put global hidden_api_blacklist_exemptions "*"')
+
+ def start_server(self):
+ """Starts the server on the remote device.
+
+ This function starts the snippet server with adb command, checks the
+ protocol version of the server, parses device port from the server
+ output and sets it to self.device_port.
+
+ Raises:
+ errors.ServerStartProtocolError: if the protocol reported by the server
+ startup process is unknown.
+ errors.ServerStartError: if failed to start the server or process the
+ server output.
+ """
+ persists_shell_cmd = self._get_persisting_command()
+ self.log.debug('Snippet server for package %s is using protocol %d.%d',
+ self.package, _PROTOCOL_MAJOR_VERSION,
+ _PROTOCOL_MINOR_VERSION)
+ cmd = _LAUNCH_CMD.format(shell_cmd=persists_shell_cmd,
+ user=self._get_user_command_string(),
+ snippet_package=self.package)
+ self._proc = self._run_adb_cmd(cmd)
+
+ # Check protocol version and get the device port
+ line = self._read_protocol_line()
+ match = re.match('^SNIPPET START, PROTOCOL ([0-9]+) ([0-9]+)$', line)
+ if not match or int(match.group(1)) != _PROTOCOL_MAJOR_VERSION:
+ raise errors.ServerStartProtocolError(self._device, line)
+
+ line = self._read_protocol_line()
+ match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line)
+ if not match:
+ raise errors.ServerStartProtocolError(self._device, line)
+ self.device_port = int(match.group(1))
+
+ def _run_adb_cmd(self, cmd):
+ """Starts a long-running adb subprocess and returns it immediately."""
+ adb_cmd = [adb.ADB]
+ if self._adb.serial:
+ adb_cmd += ['-s', self._adb.serial]
+ adb_cmd += ['shell', cmd]
+ return utils.start_standing_subprocess(adb_cmd, shell=False)
+
+ def _get_persisting_command(self):
+ """Returns the path of a persisting command if available."""
+ for command in [_SETSID_COMMAND, _NOHUP_COMMAND]:
+ try:
+ if command in self._adb.shell(['which', command]).decode('utf-8'):
+ return command
+ except adb.AdbError:
+ continue
+
+ self.log.warning(
+ 'No %s and %s commands available to launch instrument '
+ 'persistently, tests that depend on UiAutomator and '
+ 'at the same time perform USB disconnections may fail.',
+ _SETSID_COMMAND, _NOHUP_COMMAND)
+ return ''
+
+ def _get_user_command_string(self):
+ """Gets the appropriate command argument for specifying device user ID.
+
+ By default, this client operates within the current user. We
+ don't add the `--user {ID}` argument when Android's SDK is below 24,
+ where multi-user support is not well implemented.
+
+ Returns:
+ A string of the command argument section to be formatted into
+ adb commands.
+ """
+ sdk_version = int(self._device.build_info['build_version_sdk'])
+ if sdk_version < 24:
+ return ''
+ return f'--user {self.user_id}'
+
+ def _read_protocol_line(self):
+ """Reads the next line of instrumentation output relevant to snippets.
+
+ This method will skip over lines that don't start with 'SNIPPET ' or
+ 'INSTRUMENTATION_RESULT:'.
+
+ Returns:
+ A string for the next line of snippet-related instrumentation output,
+ stripped.
+
+ Raises:
+ errors.ServerStartError: If EOF is reached without any protocol lines
+ being read.
+ """
+ while True:
+ line = self._proc.stdout.readline().decode('utf-8')
+ if not line:
+ raise errors.ServerStartError(
+ self._device, 'Unexpected EOF when waiting for server to start.')
+
+ # readline() uses an empty string to mark EOF, and a single newline
+ # to mark regular empty lines in the output. Don't move the strip()
+ # call above the truthiness check, or this method will start
+ # considering any blank output line to be EOF.
+ line = line.strip()
+ if (line.startswith('INSTRUMENTATION_RESULT:') or
+ line.startswith('SNIPPET ')):
+ self.log.debug('Accepted line from instrumentation output: "%s"', line)
+ return line
+
+ self.log.debug('Discarded line from instrumentation output: "%s"', line)
+
+ def stop(self):
+ """Releases all the resources acquired in `initialize`.
+
+ This function releases following resources:
+ * Stop the standing server subprocess running on the host side.
+ * Stop the snippet server running on the device side.
+
+ Raises:
+ android_device_lib_errors.DeviceError: if the server exited with errors on
+ the device side.
+ """
+ # TODO(mhaoli): This function is only partially implemented because we
+ # have not implemented the functionality of making connections in this
+ # class.
+ self.log.debug('Stopping snippet package %s.', self.package)
+ self._stop_server()
+ self.log.debug('Snippet package %s stopped.', self.package)
+
+ def _stop_server(self):
+ """Releases all the resources acquired in `start_server`.
+
+ Raises:
+ android_device_lib_errors.DeviceError: if the server exited with errors on
+ the device side.
+ """
+ # Although killing the snippet server would abort this subprocess anyway, we
+ # want to call stop_standing_subprocess() to perform a health check,
+ # print the failure stack trace if there was any, and reap it from the
+ # process table. Note that it's much more important to ensure releasing all
+ # the allocated resources on the host side than on the remote device side.
+
+ # Stop the standing server subprocess running on the host side.
+ if self._proc:
+ utils.stop_standing_subprocess(self._proc)
+ self._proc = None
+
+ # Send the stop signal to the server running on the device side.
+ out = self._adb.shell(
+ _STOP_CMD.format(snippet_package=self.package,
+ user=self._get_user_command_string())).decode('utf-8')
+
+ if 'OK (0 tests)' not in out:
+ raise android_device_lib_errors.DeviceError(
+ self._device,
+ f'Failed to stop existing apk. Unexpected output: {out}.')
+
+ # TODO(mhaoli): Temporally override these abstract methods so that we can
+ # initialize the instances in unit tests. We are implementing these functions
+ # in the next PR as soon as possible.
+ def make_connection(self):
+ raise NotImplementedError('To be implemented.')
+
+ def close_connection(self):
+ raise NotImplementedError('To be implemented.')
+
+ def __del__(self):
+ # Override the destructor to not call close_connection for now.
+ pass
+
+ def send_rpc_request(self, request):
+ raise NotImplementedError('To be implemented.')
+
+ def check_server_proc_running(self):
+ raise NotImplementedError('To be implemented.')
+
+ def handle_callback(self, callback_id, ret_value, rpc_func_name):
+ raise NotImplementedError('To be implemented.')
+
+ def restore_server_connection(self, port=None):
+ raise NotImplementedError('To be implemented.')
diff --git a/mobly/snippet/client_base.py b/mobly/snippet/client_base.py
index 5f8e581..0009f20 100644
--- a/mobly/snippet/client_base.py
+++ b/mobly/snippet/client_base.py
@@ -116,7 +116,11 @@ class ClientBase(abc.ABC):
errors.ServerStartError: when failed to start the snippet server.
"""
- self.log.debug('Initializing the snippet package %s.', self.package)
+ # Use log.info here so people can follow along with the initialization
+ # process. Initialization can be slow, especially if there are
+ # multiple snippets, this avoids the perception that the framework
+ # is hanging for a long time doing nothing.
+ self.log.info('Initializing the snippet package %s.', self.package)
start_time = time.perf_counter()
self.log.debug('Preparing to start the snippet server of %s.', self.package)
diff --git a/mobly/snippet/errors.py b/mobly/snippet/errors.py
index d22e9fc..764aea4 100644
--- a/mobly/snippet/errors.py
+++ b/mobly/snippet/errors.py
@@ -31,8 +31,18 @@ class ServerStartError(Error):
"""Raised when failed to start the snippet server."""
+class ServerStartProtocolError(ServerStartError):
+ """Raised when protocol reported by the server startup process is unknown."""
+
+
class ServerStartPreCheckError(Error):
- """Raised when prechecks for starting the snippet server failed."""
+ """Raised when prechecks for starting the snippet server failed.
+
+ Here are some precheck examples:
+ * Whether the required software is installed on the device.
+ * Whether the configuration file required by the server startup process
+ is available.
+ """
class ApiError(Error):
diff --git a/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py b/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py
new file mode 100644
index 0000000..b97774b
--- /dev/null
+++ b/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py
@@ -0,0 +1,394 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Unit tests for mobly.controllers.android_device_lib.snippet_client_v2."""
+
+import unittest
+from unittest import mock
+
+from mobly.controllers.android_device_lib import adb
+from mobly.controllers.android_device_lib import errors as android_device_lib_errors
+from mobly.controllers.android_device_lib import snippet_client_v2
+from mobly.snippet import errors
+from tests.lib import mock_android_device
+
+MOCK_PACKAGE_NAME = 'some.package.name'
+MOCK_SERVER_PATH = f'{MOCK_PACKAGE_NAME}/{snippet_client_v2._INSTRUMENTATION_RUNNER_PACKAGE}'
+MOCK_USER_ID = 0
+
+
+class SnippetClientV2Test(unittest.TestCase):
+ """Unit tests for SnippetClientV2."""
+
+ def _make_client(self, adb_proxy=None, mock_properties=None):
+ adb_proxy = adb_proxy or mock_android_device.MockAdbProxy(
+ instrumented_packages=[
+ (MOCK_PACKAGE_NAME,
+ snippet_client_v2._INSTRUMENTATION_RUNNER_PACKAGE,
+ MOCK_PACKAGE_NAME)
+ ],
+ mock_properties=mock_properties)
+
+ device = mock.Mock()
+ device.adb = adb_proxy
+ device.adb.current_user_id = MOCK_USER_ID
+ device.build_info = {
+ 'build_version_codename':
+ adb_proxy.getprop('ro.build.version.codename'),
+ 'build_version_sdk':
+ adb_proxy.getprop('ro.build.version.sdk'),
+ }
+
+ self.client = snippet_client_v2.SnippetClientV2(MOCK_PACKAGE_NAME, device)
+
+ def _make_client_with_extra_adb_properties(self, extra_properties):
+ mock_properties = mock_android_device.DEFAULT_MOCK_PROPERTIES.copy()
+ mock_properties.update(extra_properties)
+ self._make_client(mock_properties=mock_properties)
+
+ def _mock_server_process_starting_response(self, mock_start_subprocess,
+ resp_lines):
+ mock_proc = mock_start_subprocess.return_value
+ mock_proc.stdout.readline.side_effect = resp_lines
+
+ def test_check_app_installed_normally(self):
+ """Tests that app checker runs normally when app installed correctly."""
+ self._make_client()
+ self.client._validate_snippet_app_on_device()
+
+ def test_check_app_installed_fail_app_not_installed(self):
+ """Tests that app checker fails without installing app."""
+ self._make_client(mock_android_device.MockAdbProxy())
+ expected_msg = f'.* {MOCK_PACKAGE_NAME} is not installed.'
+ with self.assertRaisesRegex(errors.ServerStartPreCheckError, expected_msg):
+ self.client._validate_snippet_app_on_device()
+
+ def test_check_app_installed_fail_not_instrumented(self):
+ """Tests that app checker fails without instrumenting app."""
+ self._make_client(
+ mock_android_device.MockAdbProxy(
+ installed_packages=[MOCK_PACKAGE_NAME]))
+ expected_msg = (
+ f'.* {MOCK_PACKAGE_NAME} is installed, but it is not instrumented.')
+ with self.assertRaisesRegex(errors.ServerStartPreCheckError, expected_msg):
+ self.client._validate_snippet_app_on_device()
+
+ def test_check_app_installed_fail_instrumentation_not_installed(self):
+ """Tests that app checker fails without installing instrumentation."""
+ self._make_client(
+ mock_android_device.MockAdbProxy(instrumented_packages=[(
+ MOCK_PACKAGE_NAME,
+ snippet_client_v2._INSTRUMENTATION_RUNNER_PACKAGE,
+ 'not.installed')]))
+ expected_msg = ('.* Instrumentation target not.installed is not installed.')
+ with self.assertRaisesRegex(errors.ServerStartPreCheckError, expected_msg):
+ self.client._validate_snippet_app_on_device()
+
+ @mock.patch.object(mock_android_device.MockAdbProxy, 'shell')
+ def test_disable_hidden_api_normally(self, mock_shell_func):
+ """Tests the disabling hidden api process works normally."""
+ self._make_client_with_extra_adb_properties({
+ 'ro.build.version.codename': 'S',
+ 'ro.build.version.sdk': '31',
+ })
+ self.client._device.is_rootable = True
+ self.client._disable_hidden_api_blocklist()
+ mock_shell_func.assert_called_with(
+ 'settings put global hidden_api_blacklist_exemptions "*"')
+
+ @mock.patch.object(mock_android_device.MockAdbProxy, 'shell')
+ def test_disable_hidden_api_low_sdk(self, mock_shell_func):
+ """Tests it doesn't disable hidden api with low SDK."""
+ self._make_client_with_extra_adb_properties({
+ 'ro.build.version.codename': 'O',
+ 'ro.build.version.sdk': '26',
+ })
+ self.client._device.is_rootable = True
+ self.client._disable_hidden_api_blocklist()
+ mock_shell_func.assert_not_called()
+
+ @mock.patch.object(mock_android_device.MockAdbProxy, 'shell')
+ def test_disable_hidden_api_non_rootable(self, mock_shell_func):
+ """Tests it doesn't disable hidden api with non-rootable device."""
+ self._make_client_with_extra_adb_properties({
+ 'ro.build.version.codename': 'S',
+ 'ro.build.version.sdk': '31',
+ })
+ self.client._device.is_rootable = False
+ self.client._disable_hidden_api_blocklist()
+ mock_shell_func.assert_not_called()
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ @mock.patch.object(mock_android_device.MockAdbProxy,
+ 'shell',
+ return_value=b'setsid')
+ def test_start_server_with_user_id(self, mock_adb, mock_start_subprocess):
+ """Tests that `--user` is added to starting command with SDK >= 24."""
+ self._make_client_with_extra_adb_properties({'ro.build.version.sdk': '30'})
+ self._mock_server_process_starting_response(
+ mock_start_subprocess,
+ resp_lines=[
+ b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+ ])
+
+ self.client.start_server()
+ start_cmd_list = [
+ 'adb', 'shell',
+ (f'setsid am instrument --user {MOCK_USER_ID} -w -e action start '
+ f'{MOCK_SERVER_PATH}')
+ ]
+ self.assertListEqual(mock_start_subprocess.call_args_list,
+ [mock.call(start_cmd_list, shell=False)])
+ self.assertEqual(self.client.device_port, 1234)
+ mock_adb.assert_called_with(['which', 'setsid'])
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ @mock.patch.object(mock_android_device.MockAdbProxy,
+ 'shell',
+ return_value=b'setsid')
+ def test_start_server_without_user_id(self, mock_adb, mock_start_subprocess):
+ """Tests that `--user` is not added to starting command on SDK < 24."""
+ self._make_client_with_extra_adb_properties({'ro.build.version.sdk': '21'})
+ self._mock_server_process_starting_response(
+ mock_start_subprocess,
+ resp_lines=[
+ b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+ ])
+
+ self.client.start_server()
+ start_cmd_list = [
+ 'adb', 'shell',
+ f'setsid am instrument -w -e action start {MOCK_SERVER_PATH}'
+ ]
+ self.assertListEqual(mock_start_subprocess.call_args_list,
+ [mock.call(start_cmd_list, shell=False)])
+ mock_adb.assert_called_with(['which', 'setsid'])
+ self.assertEqual(self.client.device_port, 1234)
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ @mock.patch.object(mock_android_device.MockAdbProxy,
+ 'shell',
+ side_effect=adb.AdbError('cmd', 'stdout', 'stderr',
+ 'ret_code'))
+ def test_start_server_without_persisting_commands(self, mock_adb,
+ mock_start_subprocess):
+ """Checks the starting server command without persisting commands."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_subprocess,
+ resp_lines=[
+ b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+ ])
+
+ self.client.start_server()
+ start_cmd_list = [
+ 'adb', 'shell',
+ (f' am instrument --user {MOCK_USER_ID} -w -e action start '
+ f'{MOCK_SERVER_PATH}')
+ ]
+ self.assertListEqual(mock_start_subprocess.call_args_list,
+ [mock.call(start_cmd_list, shell=False)])
+ mock_adb.assert_has_calls(
+ [mock.call(['which', 'setsid']),
+ mock.call(['which', 'nohup'])])
+ self.assertEqual(self.client.device_port, 1234)
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_with_nohup(self, mock_start_subprocess):
+ """Checks the starting server command with nohup."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_subprocess,
+ resp_lines=[
+ b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+ ])
+
+ def _mocked_shell(arg):
+ if 'nohup' in arg:
+ return b'nohup'
+ raise adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
+
+ self.client._adb.shell = _mocked_shell
+
+ self.client.start_server()
+ start_cmd_list = [
+ 'adb', 'shell',
+ (f'nohup am instrument --user {MOCK_USER_ID} -w -e action start '
+ f'{MOCK_SERVER_PATH}')
+ ]
+ self.assertListEqual(mock_start_subprocess.call_args_list,
+ [mock.call(start_cmd_list, shell=False)])
+ self.assertEqual(self.client.device_port, 1234)
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_with_setsid(self, mock_start_subprocess):
+ """Checks the starting server command with setsid."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_subprocess,
+ resp_lines=[
+ b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+ ])
+
+ def _mocked_shell(arg):
+ if 'setsid' in arg:
+ return b'setsid'
+ raise adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
+
+ self.client._adb.shell = _mocked_shell
+ self.client.start_server()
+ start_cmd_list = [
+ 'adb', 'shell',
+ (f'setsid am instrument --user {MOCK_USER_ID} -w -e action start '
+ f'{MOCK_SERVER_PATH}')
+ ]
+ self.assertListEqual(mock_start_subprocess.call_args_list,
+ [mock.call(start_cmd_list, shell=False)])
+ self.assertEqual(self.client.device_port, 1234)
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_server_crash(self, mock_start_standing_subprocess):
+ """Tests that starting server process crashes."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_standing_subprocess,
+ resp_lines=[b'INSTRUMENTATION_RESULT: shortMsg=Process crashed.\n'])
+ with self.assertRaisesRegex(
+ errors.ServerStartProtocolError,
+ 'INSTRUMENTATION_RESULT: shortMsg=Process crashed.'):
+ self.client.start_server()
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_unknown_protocol_version(
+ self, mock_start_standing_subprocess):
+ """Tests that starting server process reports unknown protocol version."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_standing_subprocess,
+ resp_lines=[b'SNIPPET START, PROTOCOL 99 0\n'])
+ with self.assertRaisesRegex(errors.ServerStartProtocolError,
+ 'SNIPPET START, PROTOCOL 99 0'):
+ self.client.start_server()
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_invalid_device_port(self,
+ mock_start_standing_subprocess):
+ """Tests that starting server process reports invalid device port."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_standing_subprocess,
+ resp_lines=[
+ b'SNIPPET START, PROTOCOL 1 0\n', b'SNIPPET SERVING, PORT ABC\n'
+ ])
+ with self.assertRaisesRegex(errors.ServerStartProtocolError,
+ 'SNIPPET SERVING, PORT ABC'):
+ self.client.start_server()
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_with_junk(self, mock_start_standing_subprocess):
+ """Tests that starting server process reports known protocol with junk."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_standing_subprocess,
+ resp_lines=[
+ b'This is some header junk\n',
+ b'Some phones print arbitrary output\n',
+ b'SNIPPET START, PROTOCOL 1 0\n',
+ b'Maybe in the middle too\n',
+ b'SNIPPET SERVING, PORT 123\n',
+ ])
+ self.client.start_server()
+ self.assertEqual(123, self.client.device_port)
+
+ @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+ 'utils.start_standing_subprocess')
+ def test_start_server_no_valid_line(self, mock_start_standing_subprocess):
+ """Tests that starting server process reports unknown protocol message."""
+ self._make_client()
+ self._mock_server_process_starting_response(
+ mock_start_standing_subprocess,
+ resp_lines=[
+ b'This is some header junk\n',
+ b'Some phones print arbitrary output\n',
+ b'', # readline uses '' to mark EOF
+ ])
+ with self.assertRaisesRegex(
+ errors.ServerStartError,
+ 'Unexpected EOF when waiting for server to start.'):
+ self.client.start_server()
+
+ @mock.patch('mobly.utils.stop_standing_subprocess')
+ @mock.patch.object(mock_android_device.MockAdbProxy,
+ 'shell',
+ return_value=b'OK (0 tests)')
+ def test_stop_server_normally(self, mock_android_device_shell,
+ mock_stop_standing_subprocess):
+ """Tests that stopping server process works normally."""
+ self._make_client()
+ mock_proc = mock.Mock()
+ self.client._proc = mock_proc
+ self.client.stop()
+ self.assertIs(self.client._proc, None)
+ mock_android_device_shell.assert_called_once_with(
+ f'am instrument --user {MOCK_USER_ID} -w -e action stop '
+ f'{MOCK_SERVER_PATH}')
+ mock_stop_standing_subprocess.assert_called_once_with(mock_proc)
+
+ @mock.patch('mobly.utils.stop_standing_subprocess')
+ @mock.patch.object(mock_android_device.MockAdbProxy,
+ 'shell',
+ return_value=b'OK (0 tests)')
+ def test_stop_server_server_already_cleaned(self, mock_android_device_shell,
+ mock_stop_standing_subprocess):
+ """Tests stopping server process when subprocess is already cleaned."""
+ self._make_client()
+ self.client._proc = None
+ self.client.stop()
+ self.assertIs(self.client._proc, None)
+ mock_stop_standing_subprocess.assert_not_called()
+ mock_android_device_shell.assert_called_once_with(
+ f'am instrument --user {MOCK_USER_ID} -w -e action stop '
+ f'{MOCK_SERVER_PATH}')
+
+ @mock.patch('mobly.utils.stop_standing_subprocess')
+ @mock.patch.object(mock_android_device.MockAdbProxy,
+ 'shell',
+ return_value=b'Closed with error.')
+ def test_stop_server_stop_with_error(self, mock_android_device_shell,
+ mock_stop_standing_subprocess):
+ """Tests all resources are cleaned even if stopping server has error."""
+ self._make_client()
+ mock_proc = mock.Mock()
+ self.client._proc = mock_proc
+ with self.assertRaisesRegex(android_device_lib_errors.DeviceError,
+ 'Closed with error'):
+ self.client.stop()
+
+ self.assertIs(self.client._proc, None)
+ mock_stop_standing_subprocess.assert_called_once_with(mock_proc)
+ mock_android_device_shell.assert_called_once_with(
+ f'am instrument --user {MOCK_USER_ID} -w -e action stop '
+ f'{MOCK_SERVER_PATH}')
+
+
+if __name__ == '__main__':
+ unittest.main()