blob: be281766590100dee4e9322db7974fecb8615061 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Fuchsia base test class."""
import enum
import logging
import os
from honeydew.typing import custom_types
from honeydew.interfaces.device_classes import fuchsia_device
from honeydew.interfaces.auxiliary_devices import power_switch
from honeydew.auxiliary_devices import power_switch_dmc
from mobly import base_test, signals, test_runner
from mobly_controller import fuchsia_device as fuchsia_device_mobly_controller
_LOGGER: logging.Logger = logging.getLogger(__name__)
class SnapshotOn(enum.StrEnum):
"""How often we need to collect the snapshot"""
# Once per test case.
TEARDOWN_TEST = "teardown_test"
# Once per test case on failure only.
TEARDOWN_TEST_ON_FAIL = "teardown_test_on_fail"
# Once per test class.
TEARDOWN_CLASS = "teardown_class"
# Once per test class on failure only.
TEARDOWN_CLASS_ON_FAIL = "teardown_class_on_fail"
# Do not collect snapshot
NEVER = "never"
class FuchsiaBaseTest(base_test.BaseTestClass):
"""Fuchsia base test class.
Attributes:
fuchsia_devices: List of FuchsiaDevice objects.
test_case_path: Directory pointing to a specific test case artifacts.
snapshot_on: `snapshot_on` test param value converted into SnapshotOn
Enum.
Required Mobly Test Params:
snapshot_on (str): One of "teardown_class", "teardown_class_on_fail",
"teardown_test", "on_fail".
Default value is "teardown_class_on_fail".
"""
def setup_class(self) -> None:
"""setup_class is called once before running tests.
It does the following things:
* Reads user params passed to the test
* Instantiates all fuchsia devices into self.fuchsia_devices
"""
self._any_test_failed: bool = False
self._process_user_params()
self.fuchsia_devices: list[
fuchsia_device.FuchsiaDevice
] = self.register_controller(fuchsia_device_mobly_controller)
def setup_test(self) -> None:
"""setup_test is called once before running each test.
It does the following things:
* Stores the current test case path into self.test_case_path
* Logs a info message onto device that test case has started.
"""
self._devices_not_healthy: bool = False
self.test_case_path: str = (
f"{self.log_path}/{self.current_test_info.name}"
)
os.mkdir(self.test_case_path)
self._log_message_to_devices(
message=f"Started executing '{self.current_test_info.name}' "
f"Lacewing test case...",
level=custom_types.LEVEL.INFO,
)
def teardown_test(self) -> None:
"""teardown_test is called once after running each test.
It does the following things:
* Takes snapshot of all the fuchsia devices and stores it under
test case directory if `snapshot_on` test param is set to
"teardown_test"
* Logs a info message onto device that test case has ended.
"""
self._health_check_and_recover()
if self.snapshot_on == SnapshotOn.TEARDOWN_TEST:
self._collect_snapshot(directory=self.test_case_path)
self._log_message_to_devices(
message=f"Finished executing '{self.current_test_info.name}' "
f"Lacewing test case...",
level=custom_types.LEVEL.INFO,
)
if len(os.listdir(self.test_case_path)) == 0:
os.rmdir(self.test_case_path)
if self._devices_not_healthy:
message: str = (
"One or more FuchsiaDevice's health check failed in "
"teardown_test. So failing the test case..."
)
_LOGGER.warning(message)
raise signals.TestFailure(message)
def teardown_class(self) -> None:
"""teardown_class is called once after running all tests.
It does the following things:
* Takes snapshot of all the fuchsia devices and stores it under
"<log_path>/teardown_class<_on_fail>" directory if `snapshot_on`
test param is set to "teardown_class" or "teardown_class_on_fail".
"""
self._teardown_class_artifacts: str
if self.snapshot_on == SnapshotOn.TEARDOWN_CLASS:
self._teardown_class_artifacts = f"{self.log_path}/teardown_class"
self._collect_snapshot(directory=self._teardown_class_artifacts)
elif (
self.snapshot_on == SnapshotOn.TEARDOWN_CLASS_ON_FAIL
and self._any_test_failed
):
self._teardown_class_artifacts = (
f"{self.log_path}/teardown_class_on_fail"
)
self._collect_snapshot(directory=self._teardown_class_artifacts)
def on_fail(self, _) -> None: # type: ignore[no-untyped-def]
"""on_fail is called once when a test case fails.
It does the following things:
* Takes snapshot of all the fuchsia devices and stores it under
test case directory if `snapshot_on` test param is set to
"on_fail"
"""
self._any_test_failed = True
if self.snapshot_on == SnapshotOn.TEARDOWN_TEST_ON_FAIL:
self._collect_snapshot(directory=self.test_case_path)
def _collect_snapshot(self, directory: str) -> None:
"""Collects snapshots for all the FuchsiaDevice objects and stores them
in the directory specified.
Args:
directory: Absolute path on the host where snapshot file need to be
saved.
"""
if not hasattr(self, "fuchsia_devices"):
return
_LOGGER.info(
"Collecting snapshots of all the FuchsiaDevice objects in '%s'...",
self.snapshot_on.value,
)
for fx_device in self.fuchsia_devices:
try:
fx_device.snapshot(directory=directory)
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception(
"Unable to take snapshot of %s. Failed with error: %s",
fx_device.device_name,
err,
)
def _get_controller_configs(
self, controller_type: str
) -> list[dict[str, object]]:
"""Return testbed config associated with a specific Mobly Controller.
Args:
controller_type: Controller type that is included in mobly testbed.
Ex: 'FuchsiaDevice', 'AndroidDevice' etc
Returns:
Config specified in the testbed file that is associated with
controller type provided.
Example:
```
TestBeds:
- Name: Testbed-One-X64
Controllers:
FuchsiaDevice:
- name: fuchsia-54b2-038b-6e90
transport: default
```
For above specified testbed file, calling
```
get_controller_configs(controller_type="FuchsiaDevice")
```
will return
```
[
{
'name': 'fuchsia-54b2-038b-6e90',
'transport': 'default'
}
]
```
"""
for (
controller_name,
controller_configs,
) in self.controller_configs.items():
if controller_name == controller_type:
return controller_configs
return []
def _get_device_config(
self, controller_type: str, identifier_key: str, identifier_value: str
) -> dict[str, object]:
"""Return testbed config associated with a specific device of a
particular mobly controller type.
Args:
controller_type: Controller type that is included in mobly testbed.
Ex: 'FuchsiaDevice', 'AndroidDevice' etc
identifier_key: Key to identify the specific device.
Ex: 'name', 'nodename' etc
identifier_value: Value to match from list of devices.
Ex: 'fuchsia-emulator' etc
Returns:
Config specified in the testbed file that is associated with
controller type provided.
Example:
```
TestBeds:
- Name: Testbed-One-X64
Controllers:
FuchsiaDevice:
- name: fuchsia-54b2-038b-6e90
transport: default
```
For above specified testbed file, calling
```
get_testbed_config(
controller_type="FuchsiaDevice",
identifier_key="name",
identifier_value="fuchsia-emulator")
```
will return
```
{
'name': 'fuchsia-54b2-038b-6e90',
'transport': 'default'
}
```
"""
for controller_config in self._get_controller_configs(controller_type):
if controller_config[identifier_key] == identifier_value:
_LOGGER.info(
"Device configuration associated with %s is %s",
identifier_value,
controller_config,
)
return controller_config
return {}
def _health_check_and_recover(self) -> None:
"""Ensure all FuchsiaDevice objects are healthy and if unhealthy perform
a power_cycle in an attempt to recover.
If health check failed for any device then fail the test case even if we
are able to recover the device successfully.
If the recovery fails, then abort the test class.
"""
_LOGGER.info(
"Performing health checks on all the FuchsiaDevice objects..."
)
for fx_device in self.fuchsia_devices:
try:
fx_device.health_check()
except Exception as err: # pylint: disable=broad-except
self._devices_not_healthy = True
_LOGGER.warning(
"Health check on %s failed with error '%s', will try to "
"recover the device",
fx_device.device_name,
err,
)
self._recover_device(fx_device)
_LOGGER.info(
"Successfully performed health checks and/or recoveries on all the "
"FuchsiaDevice objects..."
)
def _recover_device(self, fx_device: fuchsia_device.FuchsiaDevice) -> None:
"""Try to recover the fuchsia device by power cycling it if the test has
access to DMC.
Args:
fx_device: FuchsiaDevice object
"""
try:
dmc_power_switch: power_switch_dmc.PowerSwitchDmc = (
power_switch_dmc.PowerSwitchDmc(
device_name=fx_device.device_name
)
)
fx_device.power_cycle(power_switch=dmc_power_switch, outlet=None)
except power_switch_dmc.PowerSwitchDmcError as err:
_LOGGER.warning(
"Unable to power cycle %s as test does not have access to DMC. "
"Aborting the test class...",
fx_device.device_name,
)
raise signals.TestAbortClass(
f"{fx_device.device_name} is unhealthy and unable to recover it"
) from err
except power_switch.PowerSwitchError as err:
_LOGGER.warning(
"Power cycling %s failed with error '%s'. "
"Aborting the test class...",
fx_device.device_name,
err,
)
raise signals.TestAbortClass(
f"{fx_device.device_name} is unhealthy and failed to recover it"
) from err
def _get_transport_from_device_config(
self, fx_device: fuchsia_device.FuchsiaDevice
) -> custom_types.TRANSPORT:
"""Return the transport listed in the device config.
Args:
fx_device: FuchsiaDevice object
Returns:
custom_types.TRANSPORT associated with the device
"""
device_config: dict[str, object] = self._get_device_config(
controller_type="FuchsiaDevice",
identifier_key="name",
identifier_value=fx_device.device_name,
)
return custom_types.TRANSPORT(str(device_config["transport"]))
def _log_message_to_devices(
self, message: str, level: custom_types.LEVEL
) -> None:
"""Log message in all the Fuchsia devices.
Args:
message: Message that need to logged.
level: Log message level.
"""
for fx_device in self.fuchsia_devices:
try:
fx_device.log_message_to_device(message, level)
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception(
"Unable to log message '%s' on '%s'. Failed with error: %s",
message,
fx_device.device_name,
err,
)
def _process_user_params(self) -> None:
"""Reads, processes and stores the test params used by this module."""
_LOGGER.info(
"user_params associated with the test: %s", self.user_params
)
try:
snapshot_on: str = self.user_params.get(
"snapshot_on", SnapshotOn.TEARDOWN_CLASS_ON_FAIL.value
).lower()
self.snapshot_on: SnapshotOn = SnapshotOn(snapshot_on)
except ValueError:
_LOGGER.warning(
"Invalid value '%s' passed in 'snapshot_on' test param. "
"Valid values for the test param are: '%s'. "
"Proceeding with default value: '%s'",
snapshot_on,
[member.value for member in SnapshotOn],
SnapshotOn.TEARDOWN_CLASS_ON_FAIL.value,
)
self.snapshot_on = SnapshotOn.TEARDOWN_CLASS_ON_FAIL
_LOGGER.info(
"Frequency at which snapshots will be collected during this test "
"run is: '%s'",
self.snapshot_on,
)
if __name__ == "__main__":
test_runner.main()