diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2022-06-10 16:25:41 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2022-06-10 16:25:41 +0000 |
commit | f737ebd15c04c880f60b08134e0c47ae53a62809 (patch) | |
tree | ef513d5dafb8ab490a631e92e1f7445f2e853591 | |
parent | a5088f52488571b791ea97af98e88d20675fc17a (diff) | |
parent | ee1360ad55231b227020beca2651ab6d6f75c602 (diff) | |
download | mobly-f737ebd15c04c880f60b08134e0c47ae53a62809.tar.gz |
Snap for 8708169 from ee1360ad55231b227020beca2651ab6d6f75c602 to mainline-go-mediaprovider-release
Change-Id: Ic7082c9dea13be6291112ea148c2983e66dcafc7
-rw-r--r-- | Android.bp | 29 | ||||
-rw-r--r-- | CHANGELOG.md | 15 | ||||
-rw-r--r-- | mobly/Android.bp | 40 | ||||
-rw-r--r-- | mobly/controllers/android_device.py | 10 | ||||
-rw-r--r-- | mobly/controllers/android_device_lib/adb.py | 22 | ||||
-rw-r--r-- | mobly/controllers/android_device_lib/snippet_client_v2.py | 341 | ||||
-rw-r--r-- | mobly/snippet/__init__.py | 13 | ||||
-rw-r--r-- | mobly/snippet/client_base.py | 438 | ||||
-rw-r--r-- | mobly/snippet/errors.py | 62 | ||||
-rw-r--r-- | setup.cfg | 2 | ||||
-rwxr-xr-x | setup.py | 6 | ||||
-rw-r--r-- | tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py | 394 | ||||
-rwxr-xr-x | tests/mobly/controllers/android_device_test.py | 44 | ||||
-rw-r--r-- | tests/mobly/snippet/__init__.py | 13 | ||||
-rwxr-xr-x | tests/mobly/snippet/client_base_test.py | 424 |
15 files changed, 1817 insertions, 36 deletions
diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..bd02340 --- /dev/null +++ b/Android.bp @@ -0,0 +1,29 @@ +// +// Copyright (C) 2022 The Android Open Source Project +// +// 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. + +package { + default_applicable_licenses: ["external_python_mobly_license"], +} + +license { + name: "external_python_mobly_license", + visibility: [":__subpackages__"], + license_kinds: [ + "SPDX-license-identifier-Apache-2.0", + ], + license_text: [ + "LICENSE", + ], +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 61bbb3e..aaf6c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Mobly Release History +## Mobly Release 1.11.1: Support Test Case `repeat` and `retry`. + +### New +* Native support for `repeat` and `retry` of test cases. +* Additional assertion APIs. +* `android_device` now picks up `fastboot` devices if given `*`. + +### Fixes +* Removed the usage of `psutil` in favor of native `Py3` features. + +[Full list of changes](https://github.com/google/mobly/milestone/26?closed=1) + + ## Mobly Release 1.11: Py2 Deprecation and Repeat/Retry Support This release focuses on code quality improvement, refactoring, and legacy @@ -20,6 +33,8 @@ We are also refactoring to use 2-space indentation and unit test system. * Various improvements in Android device controller * More metadata collected for test runs +[Full list of changes](https://github.com/google/mobly/milestone/25?closed=1) + ## Mobly Release 1.10.1: Incremental fixes diff --git a/mobly/Android.bp b/mobly/Android.bp new file mode 100644 index 0000000..c6bc3f4 --- /dev/null +++ b/mobly/Android.bp @@ -0,0 +1,40 @@ +// Copyright 2022 Google Inc. All rights reserved. +// +// 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. +package { + default_applicable_licenses: ["external_python_mobly_license"], +} + +python_library { + name: "mobly", + host_supported: true, + srcs: [ + "**/*.py", + ], + version: { + py2: { + enabled: false, + }, + py3: { + enabled: true, + }, + }, + libs: [ + "py-portpicker", + "py-timeout-decorator", + "pyyaml", + "pyserial", + "typing_extensions", + ], + pkg_path: "mobly", +} diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py index d786738..40b47a4 100644 --- a/mobly/controllers/android_device.py +++ b/mobly/controllers/android_device.py @@ -149,7 +149,8 @@ def _validate_device_existence(serials): serials: list of strings, the serials of all the devices that are expected to exist. """ - valid_ad_identifiers = list_adb_devices() + list_adb_devices_by_usb_id() + valid_ad_identifiers = (list_adb_devices() + list_adb_devices_by_usb_id() + + list_fastboot_devices()) for serial in serials: if serial not in valid_ad_identifiers: raise Error(f'Android device serial "{serial}" is specified in ' @@ -241,6 +242,9 @@ def list_fastboot_devices(): """List all android devices connected to the computer that are in in fastboot mode. These are detected by fastboot. + This function doesn't raise any error if `fastboot` binary doesn't exist, + because `FastbootProxy` itself doesn't raise any error. + Returns: A list of android device serials. Empty if there's none. """ @@ -433,7 +437,7 @@ def take_bug_reports(ads, test_name=None, begin_time=None, destination=None): class BuildInfoConstants(enum.Enum): """Enums for build info constants used for AndroidDevice build info. - + Attributes: build_info_key: The key used for the build_info dictionary in AndroidDevice. system_prop_key: The key used for getting the build info from system @@ -787,7 +791,7 @@ class AndroidDevice: device is in bootloader mode. """ if self.is_bootloader: - self.log.error('Device is in fastboot mode, could not get build ' 'info.') + self.log.error('Device is in fastboot mode, could not get build info.') return if self._build_info is None or self._is_rebooting: info = {} diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py index eaaeefb..84051cd 100644 --- a/mobly/controllers/android_device_lib/adb.py +++ b/mobly/controllers/android_device_lib/adb.py @@ -159,7 +159,7 @@ class AdbProxy: def __init__(self, serial=''): self.serial = serial - def _exec_cmd(self, args, shell, timeout, stderr): + def _exec_cmd(self, args, shell, timeout, stderr) -> bytes: """Executes adb commands. Args: @@ -200,7 +200,7 @@ class AdbProxy: ret_code=ret, serial=self.serial) - def _execute_and_process_stdout(self, args, shell, handler): + def _execute_and_process_stdout(self, args, shell, handler) -> bytes: """Executes adb commands and processes the stdout with a handler. Args: @@ -285,12 +285,12 @@ class AdbProxy: adb_cmd.extend(args) return adb_cmd - def _exec_adb_cmd(self, name, args, shell, timeout, stderr): + def _exec_adb_cmd(self, name, args, shell, timeout, stderr) -> bytes: adb_cmd = self._construct_adb_cmd(name, args, shell=shell) out = self._exec_cmd(adb_cmd, shell=shell, timeout=timeout, stderr=stderr) return out - def _execute_adb_and_process_stdout(self, name, args, shell, handler): + def _execute_adb_and_process_stdout(self, name, args, shell, handler) -> bytes: adb_cmd = self._construct_adb_cmd(name, args, shell=shell) err = self._execute_and_process_stdout(adb_cmd, shell=shell, @@ -324,7 +324,7 @@ class AdbProxy: return results @property - def current_user_id(self): + def current_user_id(self) -> int: """The integer ID of the current Android user. Some adb commands require specifying a user ID to work properly. Use @@ -343,7 +343,7 @@ class AdbProxy: # Multi-user is not supported in SDK < 21, only user 0 exists. return 0 - def connect(self, address): + def connect(self, address) -> bytes: """Executes the `adb connect` command with proper status checking. Args: @@ -414,7 +414,7 @@ class AdbProxy: time.sleep(DEFAULT_GETPROPS_RETRY_SLEEP_SEC) return results - def has_shell_command(self, command): + def has_shell_command(self, command) -> bool: """Checks to see if a given check command exists on the device. Args: @@ -431,7 +431,7 @@ class AdbProxy: # an exit code > 1. return False - def forward(self, args=None, shell=False): + def forward(self, args=None, shell=False) -> bytes: with ADB_PORT_LOCK: return self._exec_adb_cmd('forward', args, @@ -439,7 +439,7 @@ class AdbProxy: timeout=None, stderr=None) - def instrument(self, package, options=None, runner=None, handler=None): + def instrument(self, package, options=None, runner=None, handler=None) -> bytes: """Runs an instrumentation command on the device. This is a convenience wrapper to avoid parameter formatting. @@ -496,7 +496,7 @@ class AdbProxy: shell=False, handler=handler) - def root(self): + def root(self) -> bytes: """Enables ADB root mode on the device. This method will retry to execute the command `adb root` when an @@ -529,7 +529,7 @@ class AdbProxy: def __getattr__(self, name): - def adb_call(args=None, shell=False, timeout=None, stderr=None): + def adb_call(args=None, shell=False, timeout=None, stderr=None) -> bytes: """Wrapper for an ADB command. Args: 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/__init__.py b/mobly/snippet/__init__.py new file mode 100644 index 0000000..ac3f9e6 --- /dev/null +++ b/mobly/snippet/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/mobly/snippet/client_base.py b/mobly/snippet/client_base.py new file mode 100644 index 0000000..0009f20 --- /dev/null +++ b/mobly/snippet/client_base.py @@ -0,0 +1,438 @@ +# 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. +"""The JSON RPC client base for communicating with snippet servers. + +The JSON RPC protocol expected by this module is: + +.. code-block:: json + + Request: + { + 'id': <Required. Monotonically increasing integer containing the ID of this + request.>, + 'method': <Required. String containing the name of the method to execute.>, + 'params': <Required. JSON array containing the arguments to the method, + `null` if no positional arguments for the RPC method.>, + 'kwargs': <Optional. JSON dict containing the keyword arguments for the + method, `null` if no positional arguments for the RPC method.>, + } + + Response: + { + 'error': <Required. String containing the error thrown by executing the + method, `null` if no error occurred.>, + 'id': <Required. Int id of request that this response maps to.>, + 'result': <Required. Arbitrary JSON object containing the result of + executing the method, `null` if the method could not be executed + or returned void.>, + 'callback': <Required. String that represents a callback ID used to + identify events associated with a particular CallbackHandler + object, `null` if this is not an asynchronous RPC.>, + } +""" + +import abc +import json +import threading +import time + +from mobly.snippet import errors + +# Maximum logging length of RPC response in DEBUG level when verbose logging is +# off. +_MAX_RPC_RESP_LOGGING_LENGTH = 1024 + +# The required field names of RPC response. +RPC_RESPONSE_REQUIRED_FIELDS = ('id', 'error', 'result', 'callback') + + +class ClientBase(abc.ABC): + """Base class for JSON RPC clients that connect to snippet servers. + + Connects to a remote device running a JSON RPC compatible server. Users call + the function `start_server` to start the server on the remote device before + sending any RPC. After sending all RPCs, users call the function `stop` + to stop the snippet server and release all the requested resources. + + Attributes: + package: str, the user-visible name of the snippet library being + communicated with. + host_port: int, the host port of this RPC client. + device_port: int, the device port of this RPC client. + log: Logger, the logger of the corresponding device controller. + verbose_logging: bool, if True, prints more detailed log + information. Default is True. + """ + + def __init__(self, package, device): + """Initializes the instance of ClientBase. + + Args: + package: str, the user-visible name of the snippet library being + communicated with. + device: DeviceController, the device object associated with a client. + """ + + self.package = package + self.host_port = None + self.device_port = None + self.log = device.log + self.verbose_logging = True + self._device = device + self._counter = None + self._lock = threading.Lock() + self._event_client = None + + def __del__(self): + self.close_connection() + + def initialize(self): + """Initializes the snippet client to interact with the remote device. + + This function contains following stages: + 1. preparing to start the snippet server. + 2. starting the snippet server on the remote device. + 3. making a connection to the snippet server. + + After this, the self.host_port and self.device_port attributes must be + set. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + errors.ServerStartPreCheckError: when prechecks for starting the server + failed. + errors.ServerStartError: when failed to start the snippet server. + """ + + # 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) + self.before_starting_server() + + try: + self.log.debug('Starting the snippet server of %s.', self.package) + self.start_server() + + self.log.debug('Making a connection to the snippet server of %s.', + self.package) + self._make_connection() + + except Exception: + self.log.error( + 'Error occurred trying to start and connect to the snippet server ' + 'of %s.', self.package) + try: + self.stop() + except Exception: # pylint: disable=broad-except + # Only prints this exception and re-raises the original exception + self.log.exception( + 'Failed to stop the snippet package %s after failure to start ' + 'and connect.', self.package) + + raise + + self.log.debug( + 'Snippet package %s initialized after %.1fs on host port %d.', + self.package, + time.perf_counter() - start_time, self.host_port) + + @abc.abstractmethod + def before_starting_server(self): + """Performs the preparation steps before starting the remote server. + + For example, subclass can check or modify the device settings at this + stage. + + Raises: + errors.ServerStartPreCheckError: when prechecks for starting the server + failed. + """ + + @abc.abstractmethod + def start_server(self): + """Starts the server on the remote device. + + The client has completed the preparations, so the client calls this + function to start the server. + """ + + def _make_connection(self): + """Proxy function of make_connection. + + This function resets the RPC id counter before calling `make_connection`. + """ + self._counter = self._id_counter() + self.make_connection() + + @abc.abstractmethod + def make_connection(self): + """Makes a connection to the snippet server on the remote device. + + This function makes a connection to the server and sends a handshake + request to ensure the server is available for upcoming RPCs. + + There are two types of connections used by snippet clients: + * The client makes a new connection each time it needs to send an RPC. + * The client makes a connection in this stage and uses it for all the RPCs. + In this case, the client should implement `close_connection` to close + the connection. + + This function uses self.host_port for communicating with the server. If + self.host_port is 0 or None, this function finds an available host port to + make the connection and set self.host_port to the found port. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + """ + + def __getattr__(self, name): + """Wrapper for python magic to turn method calls into RPCs.""" + + def rpc_call(*args, **kwargs): + return self._rpc(name, *args, **kwargs) + + return rpc_call + + def _id_counter(self): + """Returns an id generator.""" + i = 0 + while True: + yield i + i += 1 + + def set_snippet_client_verbose_logging(self, verbose): + """Switches verbose logging. True for logging full RPC responses. + + By default it will write full messages returned from RPCs. Turning off the + verbose logging will result in writing no more than + _MAX_RPC_RESP_LOGGING_LENGTH characters per RPC returned string. + + _MAX_RPC_RESP_LOGGING_LENGTH will be set to 1024 by default. The length + contains the full RPC response in JSON format, not just the RPC result + field. + + Args: + verbose: bool, if True, turns on verbose logging, otherwise turns off. + """ + self.log.info('Sets verbose logging to %s.', verbose) + self.verbose_logging = verbose + + @abc.abstractmethod + def restore_server_connection(self, port=None): + """Reconnects to the server after the device was disconnected. + + Instead of creating a new instance of the client: + - Uses the given port (or finds a new available host_port if 0 or None is + given). + - Tries to connect to the remote server with the selected port. + + Args: + port: int, if given, this is the host port from which to connect to the + remote device port. Otherwise, finds a new available port as host + port. + + Raises: + errors.ServerRestoreConnectionError: when failed to restore the connection + to the snippet server. + """ + + def _rpc(self, rpc_func_name, *args, **kwargs): + """Sends an RPC to the server. + + Args: + rpc_func_name: str, the name of the snippet function to execute on the + server. + *args: any, the positional arguments of the RPC request. + **kwargs: any, the keyword arguments of the RPC request. + + Returns: + The result of the RPC. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + errors.ApiError: the RPC went through, however executed with errors. + """ + try: + self.check_server_proc_running() + except Exception: + self.log.error( + 'Server process running check failed, skip sending RPC method(%s).', + rpc_func_name) + raise + + with self._lock: + rpc_id = next(self._counter) + request = self._gen_rpc_request(rpc_id, rpc_func_name, *args, **kwargs) + + self.log.debug('Sending RPC request %s.', request) + response = self.send_rpc_request(request) + self.log.debug('RPC request sent.') + + if self.verbose_logging or _MAX_RPC_RESP_LOGGING_LENGTH >= len(response): + self.log.debug('Snippet received: %s', response) + else: + self.log.debug('Snippet received: %s... %d chars are truncated', + response[:_MAX_RPC_RESP_LOGGING_LENGTH], + len(response) - _MAX_RPC_RESP_LOGGING_LENGTH) + + response_decoded = self._decode_response_string_and_validate_format( + rpc_id, response) + return self._handle_rpc_response(rpc_func_name, response_decoded) + + @abc.abstractmethod + def check_server_proc_running(self): + """Checks whether the server is still running. + + If the server is not running, it throws an error. As this function is called + each time the client tries to send an RPC, this should be a quick check + without affecting performance. Otherwise it is fine to not check anything. + + Raises: + errors.ServerDiedError: if the server died. + """ + + def _gen_rpc_request(self, rpc_id, rpc_func_name, *args, **kwargs): + """Generates the JSON RPC request. + + In the generated JSON string, the fields are sorted by keys in ascending + order. + + Args: + rpc_id: int, the id of this RPC. + rpc_func_name: str, the name of the snippet function to execute + on the server. + *args: any, the positional arguments of the RPC. + **kwargs: any, the keyword arguments of the RPC. + + Returns: + A string of the JSON RPC request. + """ + data = {'id': rpc_id, 'method': rpc_func_name, 'params': args} + if kwargs: + data['kwargs'] = kwargs + return json.dumps(data, sort_keys=True) + + @abc.abstractmethod + def send_rpc_request(self, request): + """Sends the JSON RPC request to the server and gets a response. + + Note that the request and response are both in string format. So if the + connection with server provides interfaces in bytes format, please + transform them to string in the implementation of this function. + + Args: + request: str, a string of the RPC request. + + Returns: + A string of the RPC response. + + Raises: + errors.ProtocolError: something went wrong when exchanging data with the + server. + """ + + def _decode_response_string_and_validate_format(self, rpc_id, response): + """Decodes response JSON string to python dict and validates its format. + + Args: + rpc_id: int, the actual id of this RPC. It should be the same with the id + in the response, otherwise throws an error. + response: str, the JSON string of the RPC response. + + Returns: + A dict decoded from the response JSON string. + + Raises: + errors.ProtocolError: if the response format is invalid. + """ + if not response: + raise errors.ProtocolError(self._device, + errors.ProtocolError.NO_RESPONSE_FROM_SERVER) + + result = json.loads(response) + for field_name in RPC_RESPONSE_REQUIRED_FIELDS: + if field_name not in result: + raise errors.ProtocolError( + self._device, + errors.ProtocolError.RESPONSE_MISSING_FIELD % field_name) + + if result['id'] != rpc_id: + raise errors.ProtocolError(self._device, + errors.ProtocolError.MISMATCHED_API_ID) + + return result + + def _handle_rpc_response(self, rpc_func_name, response): + """Handles the content of RPC response. + + If the RPC response contains error information, it throws an error. If the + RPC is asynchronous, it creates and returns a callback handler + object. Otherwise, it returns the result field of the response. + + Args: + rpc_func_name: str, the name of the snippet function that this RPC + triggered on the snippet server. + response: dict, the object decoded from the response JSON string. + + Returns: + The result of the RPC. If synchronous RPC, it is the result field of the + response. If asynchronous RPC, it is the callback handler object. + + Raises: + errors.ApiError: if the snippet function executed with errors. + """ + + if response['error']: + raise errors.ApiError(self._device, response['error']) + if response['callback'] is not None: + return self.handle_callback(response['callback'], response['result'], + rpc_func_name) + return response['result'] + + @abc.abstractmethod + def handle_callback(self, callback_id, ret_value, rpc_func_name): + """Creates a callback handler for the asynchronous RPC. + + Args: + callback_id: str, the callback ID for creating a callback handler object. + ret_value: any, the result field of the RPC response. + rpc_func_name: str, the name of the snippet function executed on the + server. + + Returns: + The callback handler object. + """ + + @abc.abstractmethod + def stop(self): + """Releases all the resources acquired in `initialize`.""" + + @abc.abstractmethod + def close_connection(self): + """Closes the connection to the snippet server on the device. + + This is a unilateral closing from the client side, without tearing down + the snippet server running on the device. + + The connection to the snippet server can be re-established by calling + `restore_server_connection`. + """ diff --git a/mobly/snippet/errors.py b/mobly/snippet/errors.py new file mode 100644 index 0000000..764aea4 --- /dev/null +++ b/mobly/snippet/errors.py @@ -0,0 +1,62 @@ +# 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. +"""Module for errors thrown from snippet client objects.""" +# TODO(mhaoli): Package `mobly.snippet` should not import errors from +# android_device_lib. However, android_device_lib.DeviceError is the base error +# for the errors thrown from Android snippet clients and device controllers. +# We should resolve this legacy problem. +from mobly.controllers.android_device_lib import errors + + +class Error(errors.DeviceError): + """Root error type for snippet clients.""" + + +class ServerRestoreConnectionError(Error): + """Raised when failed to restore the connection with the snippet server.""" + + +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. + + 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): + """Raised when remote API reported an error.""" + + +class ProtocolError(Error): + """Raised when there was an error in exchanging data with server.""" + NO_RESPONSE_FROM_HANDSHAKE = 'No response from handshake.' + NO_RESPONSE_FROM_SERVER = ('No response from server. ' + 'Check the device logcat for crashes.') + MISMATCHED_API_ID = 'RPC request-response ID mismatch.' + RESPONSE_MISSING_FIELD = 'Missing required field in the RPC response: %s.' + + +class ServerDiedError(Error): + """Raised if the snippet server died before all tests finish.""" @@ -1,5 +1,5 @@ [metadata] -description-file = README.md +description_file = README.md [tool:pytest] python_classes = *Test python_files = *_test.py @@ -18,7 +18,7 @@ from setuptools.command import test import sys install_requires = [ - 'portpicker', 'pyserial', 'pyyaml', 'timeout_decorator', 'typing_extensions' + 'portpicker', 'pyserial', 'pyyaml', 'timeout_decorator', 'typing_extensions>=4.1.1' ] if platform.system() == 'Windows': @@ -44,13 +44,13 @@ class PyTest(test.test): def main(): setuptools.setup( name='mobly', - version='1.11', + version='1.11.1', maintainer='Ang Li', maintainer_email='mobly-github@googlegroups.com', description='Automation framework for special end-to-end test cases', license='Apache2.0', url='https://github.com/google/mobly', - download_url='https://github.com/google/mobly/tarball/1.11', + download_url='https://github.com/google/mobly/tarball/1.11.1', packages=setuptools.find_packages(exclude=['tests']), include_package_data=False, scripts=['tools/sl4a_shell.py', 'tools/snippet_shell.py'], 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() diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py index a98ecf3..6adb8f5 100755 --- a/tests/mobly/controllers/android_device_test.py +++ b/tests/mobly/controllers/android_device_test.py @@ -132,20 +132,26 @@ class AndroidDeviceTest(unittest.TestCase): with self.assertRaisesRegex(android_device.Error, expected_msg): android_device.create([1]) + @mock.patch('mobly.controllers.android_device.list_fastboot_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id') @mock.patch('mobly.controllers.android_device.AndroidDevice') - def test_get_instances(self, mock_ad_class, mock_list_adb_usb, mock_list_adb): + def test_get_instances(self, mock_ad_class, mock_list_adb_usb, mock_list_adb, + mock_list_fastboot): + mock_list_fastboot.return_value = ['0'] mock_list_adb.return_value = ['1'] mock_list_adb_usb.return_value = [] - android_device.get_instances(['1']) - mock_ad_class.assert_called_with('1') + android_device.get_instances(['0', '1']) + mock_ad_class.assert_any_call('0') + mock_ad_class.assert_any_call('1') + @mock.patch('mobly.controllers.android_device.list_fastboot_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id') @mock.patch('mobly.controllers.android_device.AndroidDevice') def test_get_instances_do_not_exist(self, mock_ad_class, mock_list_adb_usb, - mock_list_adb): + mock_list_adb, mock_list_fastboot): + mock_list_fastboot.return_value = [] mock_list_adb.return_value = [] mock_list_adb_usb.return_value = [] with self.assertRaisesRegex( @@ -154,12 +160,14 @@ class AndroidDeviceTest(unittest.TestCase): ): android_device.get_instances(['1']) + @mock.patch('mobly.controllers.android_device.list_fastboot_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id') @mock.patch('mobly.controllers.android_device.AndroidDevice') def test_get_instances_with_configs(self, mock_ad_class, mock_list_adb_usb, - mock_list_adb): - mock_list_adb.return_value = ['1', '2'] + mock_list_adb, mock_list_fastboot): + mock_list_fastboot.return_value = ['1'] + mock_list_adb.return_value = ['2'] mock_list_adb_usb.return_value = [] configs = [{'serial': '1'}, {'serial': '2'}] android_device.get_instances_with_configs(configs) @@ -173,12 +181,15 @@ class AndroidDeviceTest(unittest.TestCase): f'Required value "serial" is missing in AndroidDevice config {config}'): android_device.get_instances_with_configs([config]) + @mock.patch('mobly.controllers.android_device.list_fastboot_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices') @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id') @mock.patch('mobly.controllers.android_device.AndroidDevice') def test_get_instances_with_configsdo_not_exist(self, mock_ad_class, mock_list_adb_usb, - mock_list_adb): + mock_list_adb, + mock_list_fastboot): + mock_list_fastboot.return_value = [] mock_list_adb.return_value = [] mock_list_adb_usb.return_value = [] config = {'serial': '1'} @@ -859,8 +870,8 @@ class AndroidDeviceTest(unittest.TestCase): @mock.patch('mobly.utils.create_dir') @mock.patch('mobly.logger.get_log_file_timestamp') def test_AndroidDevice_take_screenshot_with_prefix( - self, get_log_file_timestamp_mock, create_dir_mock, - FastbootProxy, MockAdbProxy): + self, get_log_file_timestamp_mock, create_dir_mock, FastbootProxy, + MockAdbProxy): get_log_file_timestamp_mock.return_value = '07-22-2019_17-53-34-450' mock_serial = '1' ad = android_device.AndroidDevice(serial=mock_serial) @@ -1141,22 +1152,19 @@ class AndroidDeviceTest(unittest.TestCase): mock_serial = '1' ad = android_device.AndroidDevice(serial=mock_serial) self.assertEqual(ad.debug_tag, '1') - with self.assertRaisesRegex( - android_device.DeviceError, - r'<AndroidDevice\|1> Something'): + with self.assertRaisesRegex(android_device.DeviceError, + r'<AndroidDevice\|1> Something'): raise android_device.DeviceError(ad, 'Something') # Verify that debug tag's setter updates the debug prefix correctly. ad.debug_tag = 'Mememe' - with self.assertRaisesRegex( - android_device.DeviceError, - r'<AndroidDevice\|Mememe> Something'): + with self.assertRaisesRegex(android_device.DeviceError, + r'<AndroidDevice\|Mememe> Something'): raise android_device.DeviceError(ad, 'Something') # Verify that repr is changed correctly. - with self.assertRaisesRegex( - Exception, - r'(<AndroidDevice\|Mememe>, \'Something\')'): + with self.assertRaisesRegex(Exception, + r'(<AndroidDevice\|Mememe>, \'Something\')'): raise Exception(ad, 'Something') @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy', diff --git a/tests/mobly/snippet/__init__.py b/tests/mobly/snippet/__init__.py new file mode 100644 index 0000000..ac3f9e6 --- /dev/null +++ b/tests/mobly/snippet/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/mobly/snippet/client_base_test.py b/tests/mobly/snippet/client_base_test.py new file mode 100755 index 0000000..d9d99bd --- /dev/null +++ b/tests/mobly/snippet/client_base_test.py @@ -0,0 +1,424 @@ +# 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. +"""Unit tests for mobly.snippet.client_base.""" + +import logging +import random +import string +import unittest +from unittest import mock + +from mobly.snippet import client_base +from mobly.snippet import errors + + +def _generate_fix_length_rpc_response( + response_length, + template='{"id": 0, "result": "%s", "error": null, "callback": null}'): + """Generates an RPC response string with specified length. + + This function generates a random string and formats the template with the + generated random string to get the response string. This function formats + the template with printf style string formatting. + + Args: + response_length: int, the length of the response string to generate. + template: str, the template used for generating the response string. + + Returns: + The generated response string. + + Raises: + ValueError: if the specified length is too small to generate a response. + """ + # We need to -2 here because the string formatting will delete the substring + # '%s' in the template, of which the length is 2. + result_length = response_length - (len(template) - 2) + if result_length < 0: + raise ValueError(f'The response_length should be no smaller than ' + f'template_length + 2. Got response_length ' + f'{response_length}, template_length {len(template)}.') + chars = string.ascii_letters + string.digits + return template % ''.join(random.choice(chars) for _ in range(result_length)) + + +class FakeClient(client_base.ClientBase): + """Fake client class for unit tests.""" + + def __init__(self): + """Initializes the instance by mocking a device controller.""" + mock_device = mock.Mock() + mock_device.log = logging + super().__init__(package='FakeClient', device=mock_device) + + # Override abstract methods to enable initialization + def before_starting_server(self): + pass + + def start_server(self): + pass + + def make_connection(self): + pass + + def restore_server_connection(self, port=None): + pass + + def check_server_proc_running(self): + pass + + def send_rpc_request(self, request): + pass + + def handle_callback(self, callback_id, ret_value, rpc_func_name): + pass + + def stop(self): + pass + + def close_connection(self): + pass + + +class ClientBaseTest(unittest.TestCase): + """Unit tests for mobly.snippet.client_base.ClientBase.""" + + def setUp(self): + super().setUp() + self.client = FakeClient() + self.client.host_port = 12345 + + @mock.patch.object(FakeClient, 'before_starting_server') + @mock.patch.object(FakeClient, 'start_server') + @mock.patch.object(FakeClient, '_make_connection') + def test_init_server_stage_order(self, mock_make_conn_func, mock_start_func, + mock_before_func): + """Test that initialization runs its stages in expected order.""" + order_manager = mock.Mock() + order_manager.attach_mock(mock_before_func, 'mock_before_func') + order_manager.attach_mock(mock_start_func, 'mock_start_func') + order_manager.attach_mock(mock_make_conn_func, 'mock_make_conn_func') + + self.client.initialize() + + expected_call_order = [ + mock.call.mock_before_func(), + mock.call.mock_start_func(), + mock.call.mock_make_conn_func(), + ] + self.assertListEqual(order_manager.mock_calls, expected_call_order) + + @mock.patch.object(FakeClient, 'stop') + @mock.patch.object(FakeClient, 'before_starting_server') + def test_init_server_before_starting_server_fail(self, mock_before_func, + mock_stop_func): + """Test before_starting_server stage of initialization fails.""" + mock_before_func.side_effect = Exception('ha') + + with self.assertRaisesRegex(Exception, 'ha'): + self.client.initialize() + mock_stop_func.assert_not_called() + + @mock.patch.object(FakeClient, 'stop') + @mock.patch.object(FakeClient, 'start_server') + def test_init_server_start_server_fail(self, mock_start_func, mock_stop_func): + """Test start_server stage of initialization fails.""" + mock_start_func.side_effect = Exception('ha') + + with self.assertRaisesRegex(Exception, 'ha'): + self.client.initialize() + mock_stop_func.assert_called() + + @mock.patch.object(FakeClient, 'stop') + @mock.patch.object(FakeClient, '_make_connection') + def test_init_server_make_connection_fail(self, mock_make_conn_func, + mock_stop_func): + """Test _make_connection stage of initialization fails.""" + mock_make_conn_func.side_effect = Exception('ha') + + with self.assertRaisesRegex(Exception, 'ha'): + self.client.initialize() + mock_stop_func.assert_called() + + @mock.patch.object(FakeClient, 'check_server_proc_running') + @mock.patch.object(FakeClient, '_gen_rpc_request') + @mock.patch.object(FakeClient, 'send_rpc_request') + @mock.patch.object(FakeClient, '_decode_response_string_and_validate_format') + @mock.patch.object(FakeClient, '_handle_rpc_response') + def test_rpc_stage_dependencies(self, mock_handle_resp, mock_decode_resp_str, + mock_send_request, mock_gen_request, + mock_precheck): + """Test the internal dependencies when sending an RPC. + + When sending an RPC, it calls multiple functions in specific order, and + each function uses the output of the previously called function. This test + case checks above dependencies. + + Args: + mock_handle_resp: the mock function of FakeClient._handle_rpc_response. + mock_decode_resp_str: the mock function of + FakeClient._decode_response_string_and_validate_format. + mock_send_request: the mock function of FakeClient.send_rpc_request. + mock_gen_request: the mock function of FakeClient._gen_rpc_request. + mock_precheck: the mock function of FakeClient.check_server_proc_running. + """ + self.client.initialize() + + expected_response_str = ('{"id": 0, "result": 123, "error": null, ' + '"callback": null}') + expected_response_dict = { + 'id': 0, + 'result': 123, + 'error': None, + 'callback': None, + } + expected_request = ('{"id": 10, "method": "some_rpc", "params": [1, 2],' + '"kwargs": {"test_key": 3}') + expected_result = 123 + + mock_gen_request.return_value = expected_request + mock_send_request.return_value = expected_response_str + mock_decode_resp_str.return_value = expected_response_dict + mock_handle_resp.return_value = expected_result + rpc_result = self.client.some_rpc(1, 2, test_key=3) + + mock_precheck.assert_called() + mock_gen_request.assert_called_with(0, 'some_rpc', 1, 2, test_key=3) + mock_send_request.assert_called_with(expected_request) + mock_decode_resp_str.assert_called_with(0, expected_response_str) + mock_handle_resp.assert_called_with('some_rpc', expected_response_dict) + self.assertEqual(rpc_result, expected_result) + + @mock.patch.object(FakeClient, 'check_server_proc_running') + @mock.patch.object(FakeClient, '_gen_rpc_request') + @mock.patch.object(FakeClient, 'send_rpc_request') + @mock.patch.object(FakeClient, '_decode_response_string_and_validate_format') + @mock.patch.object(FakeClient, '_handle_rpc_response') + def test_rpc_precheck_fail(self, mock_handle_resp, mock_decode_resp_str, + mock_send_request, mock_gen_request, + mock_precheck): + """Test when RPC precheck fails it will skip sending the RPC.""" + self.client.initialize() + mock_precheck.side_effect = Exception('server_died') + + with self.assertRaisesRegex(Exception, 'server_died'): + self.client.some_rpc(1, 2) + + mock_gen_request.assert_not_called() + mock_send_request.assert_not_called() + mock_handle_resp.assert_not_called() + mock_decode_resp_str.assert_not_called() + + def test_gen_request(self): + """Test generating an RPC request. + + Test that _gen_rpc_request returns a string represents a JSON dict + with all required fields. + """ + request = self.client._gen_rpc_request(0, 'test_rpc', 1, 2, test_key=3) + expected_result = ('{"id": 0, "kwargs": {"test_key": 3}, ' + '"method": "test_rpc", "params": [1, 2]}') + self.assertEqual(request, expected_result) + + def test_gen_request_without_kwargs(self): + """Test no keyword arguments. + + Test that _gen_rpc_request ignores the kwargs field when no + keyword arguments. + """ + request = self.client._gen_rpc_request(0, 'test_rpc', 1, 2) + expected_result = '{"id": 0, "method": "test_rpc", "params": [1, 2]}' + self.assertEqual(request, expected_result) + + def test_rpc_no_response(self): + """Test parsing an empty RPC response.""" + with self.assertRaisesRegex(errors.ProtocolError, + errors.ProtocolError.NO_RESPONSE_FROM_SERVER): + self.client._decode_response_string_and_validate_format(0, '') + + with self.assertRaisesRegex(errors.ProtocolError, + errors.ProtocolError.NO_RESPONSE_FROM_SERVER): + self.client._decode_response_string_and_validate_format(0, None) + + def test_rpc_response_missing_fields(self): + """Test parsing an RPC response that misses some required fields.""" + mock_resp_without_id = '{"result": 123, "error": null, "callback": null}' + with self.assertRaisesRegex( + errors.ProtocolError, + errors.ProtocolError.RESPONSE_MISSING_FIELD % 'id'): + self.client._decode_response_string_and_validate_format( + 10, mock_resp_without_id) + + mock_resp_without_result = '{"id": 10, "error": null, "callback": null}' + with self.assertRaisesRegex( + errors.ProtocolError, + errors.ProtocolError.RESPONSE_MISSING_FIELD % 'result'): + self.client._decode_response_string_and_validate_format( + 10, mock_resp_without_result) + + mock_resp_without_error = '{"id": 10, "result": 123, "callback": null}' + with self.assertRaisesRegex( + errors.ProtocolError, + errors.ProtocolError.RESPONSE_MISSING_FIELD % 'error'): + self.client._decode_response_string_and_validate_format( + 10, mock_resp_without_error) + + mock_resp_without_callback = '{"id": 10, "result": 123, "error": null}' + with self.assertRaisesRegex( + errors.ProtocolError, + errors.ProtocolError.RESPONSE_MISSING_FIELD % 'callback'): + self.client._decode_response_string_and_validate_format( + 10, mock_resp_without_callback) + + def test_rpc_response_error(self): + """Test parsing an RPC response with a non-empty error field.""" + mock_resp_with_error = { + 'id': 10, + 'result': 123, + 'error': 'some_error', + 'callback': None, + } + with self.assertRaisesRegex(errors.ApiError, 'some_error'): + self.client._handle_rpc_response('some_rpc', mock_resp_with_error) + + def test_rpc_response_callback(self): + """Test parsing response function handles the callback field well.""" + # Call handle_callback function if the "callback" field is not None + mock_resp_with_callback = { + 'id': 10, + 'result': 123, + 'error': None, + 'callback': '1-0' + } + with mock.patch.object(self.client, + 'handle_callback') as mock_handle_callback: + expected_callback = mock.Mock() + mock_handle_callback.return_value = expected_callback + + rpc_result = self.client._handle_rpc_response('some_rpc', + mock_resp_with_callback) + mock_handle_callback.assert_called_with('1-0', 123, 'some_rpc') + # Ensure the RPC function returns what handle_callback returned + self.assertIs(expected_callback, rpc_result) + + # Do not call handle_callback function if the "callback" field is None + mock_resp_without_callback = { + 'id': 10, + 'result': 123, + 'error': None, + 'callback': None + } + with mock.patch.object(self.client, + 'handle_callback') as mock_handle_callback: + self.client._handle_rpc_response('some_rpc', mock_resp_without_callback) + mock_handle_callback.assert_not_called() + + def test_rpc_response_id_mismatch(self): + """Test parsing an RPC response with a wrong id.""" + right_id = 5 + wrong_id = 20 + resp = f'{{"id": {right_id}, "result": 1, "error": null, "callback": null}}' + + with self.assertRaisesRegex(errors.ProtocolError, + errors.ProtocolError.MISMATCHED_API_ID): + self.client._decode_response_string_and_validate_format(wrong_id, resp) + + @mock.patch.object(FakeClient, 'send_rpc_request') + def test_rpc_verbose_logging_with_long_string(self, mock_send_request): + """Test RPC response isn't truncated when verbose logging is on.""" + mock_log = mock.Mock() + self.client.log = mock_log + self.client.set_snippet_client_verbose_logging(True) + self.client.initialize() + + resp = _generate_fix_length_rpc_response( + client_base._MAX_RPC_RESP_LOGGING_LENGTH * 2) + mock_send_request.return_value = resp + self.client.some_rpc(1, 2) + mock_log.debug.assert_called_with('Snippet received: %s', resp) + + @mock.patch.object(FakeClient, 'send_rpc_request') + def test_rpc_truncated_logging_short_response(self, mock_send_request): + """Test RPC response isn't truncated with small length.""" + mock_log = mock.Mock() + self.client.log = mock_log + self.client.set_snippet_client_verbose_logging(False) + self.client.initialize() + + resp = _generate_fix_length_rpc_response( + int(client_base._MAX_RPC_RESP_LOGGING_LENGTH // 2)) + mock_send_request.return_value = resp + self.client.some_rpc(1, 2) + mock_log.debug.assert_called_with('Snippet received: %s', resp) + + @mock.patch.object(FakeClient, 'send_rpc_request') + def test_rpc_truncated_logging_fit_size_response(self, mock_send_request): + """Test RPC response isn't truncated with length equal to the threshold.""" + mock_log = mock.Mock() + self.client.log = mock_log + self.client.set_snippet_client_verbose_logging(False) + self.client.initialize() + + resp = _generate_fix_length_rpc_response( + client_base._MAX_RPC_RESP_LOGGING_LENGTH) + mock_send_request.return_value = resp + self.client.some_rpc(1, 2) + mock_log.debug.assert_called_with('Snippet received: %s', resp) + + @mock.patch.object(FakeClient, 'send_rpc_request') + def test_rpc_truncated_logging_long_response(self, mock_send_request): + """Test RPC response is truncated with length larger than the threshold.""" + mock_log = mock.Mock() + self.client.log = mock_log + self.client.set_snippet_client_verbose_logging(False) + self.client.initialize() + + max_len = client_base._MAX_RPC_RESP_LOGGING_LENGTH + resp = _generate_fix_length_rpc_response(max_len * 40) + mock_send_request.return_value = resp + self.client.some_rpc(1, 2) + mock_log.debug.assert_called_with( + 'Snippet received: %s... %d chars are truncated', + resp[:client_base._MAX_RPC_RESP_LOGGING_LENGTH], + len(resp) - max_len) + + @mock.patch.object(FakeClient, 'send_rpc_request') + def test_rpc_call_increment_counter(self, mock_send_request): + """Test that with each RPC call the counter is incremented by 1.""" + self.client.initialize() + resp = '{"id": %d, "result": 123, "error": null, "callback": null}' + mock_send_request.side_effect = (resp % (i,) for i in range(10)) + + for _ in range(0, 10): + self.client.some_rpc() + + self.assertEqual(next(self.client._counter), 10) + + @mock.patch.object(FakeClient, 'send_rpc_request') + def test_init_connection_reset_counter(self, mock_send_request): + """Test that _make_connection resets the counter to zero.""" + self.client.initialize() + resp = '{"id": %d, "result": 123, "error": null, "callback": null}' + mock_send_request.side_effect = (resp % (i,) for i in range(10)) + + for _ in range(0, 10): + self.client.some_rpc() + + self.assertEqual(next(self.client._counter), 10) + self.client._make_connection() + self.assertEqual(next(self.client._counter), 0) + + +if __name__ == '__main__': + unittest.main() |