diff options
author | Minghao Li <minghaoli@google.com> | 2022-04-15 11:48:02 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-04-14 20:48:02 -0700 |
commit | 6d7b6210372468cc05699d53877c338029f24ea2 (patch) | |
tree | eafeca59bc672113c4ed12adffa2c1d49f125407 | |
parent | 38826e31e9319a7b93ff1b5df38ce203aa443844 (diff) | |
download | mobly-6d7b6210372468cc05699d53877c338029f24ea2.tar.gz |
Adding Snippet Client V2 for Android, Step 1 (#804)
-rw-r--r-- | mobly/controllers/android_device_lib/snippet_client_v2.py | 341 | ||||
-rw-r--r-- | mobly/snippet/client_base.py | 6 | ||||
-rw-r--r-- | mobly/snippet/errors.py | 12 | ||||
-rw-r--r-- | tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py | 394 |
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() |