Initial commit

This commit is contained in:
alexpete
2021-03-05 11:26:34 -08:00
commit a10351f38d
27091 changed files with 5521199 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
INTRODUCTION
------------
LyTestTools is a Python project that contains a collection of testing tools
developed by the Lumberyard Test Tech team. The project contains
the following tools:
* Workspace Manager:
A library to manipulate Lumberyard installations
* Launchers:
A library to test the game in a variety of platforms
REQUIREMENTS
------------
* Python 3.7.5 (64-bit)
It is recommended that you completely remove any other versions of Python
installed on your system.
INSTALL
-----------
It is recommended to set up these these tools with Lumberyard's CMake build commands.
Assuming CMake is already setup on your operating system, below are some sample build commands:
cd /path/to/lumberyard/dev/
mkdir windows_vs2019
cd windows_vs2019
cmake -E time cmake --build . --target ALL_BUILD --config profile
NOTE:
Using the above command also adds LyTestTools to the PYTHONPATH OS environment variable.
Additionally, some CTest scripts will add the Python interpreter path to the PYTHON OS environment variable.
There is some LyTestTools functionality that will search for these, so feel free to populate them manually.
To manually install the project in development mode using your own installed Python interpreter:
cd /path/to/lumberyard/dev/Tools/LyTestTools/
/path/to/your/python -m pip install -e .
For console/mobile testing, update the following .ini file in your root user directory:
i.e. C:/Users/myusername/ly_test_tools/devices.ini (a.k.a. %USERPROFILE%/ly_test_tools/devices.ini)
You will need to add a section for the device, and a key holding the device identifier value (usually an IP or ID).
It should look similar to this for each device:
[android]
id = 988939353955305449
[gameconsole]
ip = 192.168.1.1
[gameconsole2]
ip = 192.168.1.2
PACKAGE STRUCTURE
-----------------
The project is organized into packages. Each package corresponds to a tool:
- LyTestTools.ly_test_tools._internal: contains logging setup, pytest fixture, and lumberyard workspace manager modules
- LyTestTools.ly_test_tools.builtin: builtin helpers and fixtures for quickly writing tests
- LyTestTools.ly_test_tools.console: modules used for consoles
- LyTestTools.ly_test_tools.environment: functions related to file/process management and cleanup
- LyTestTools.ly_test_tools.image: modules related to image capturing and processing
- LyTestTools.ly_test_tools.launchers: game launchers library
- LyTestTools.ly_test_tools.log: modules for interacting with generated or existing log files
- LyTestTools.ly_test_tools.lumberyard: modules used to interact with lumberyard
- LyTestTools.ly_test_tools.mobile: modules used for android/ios
- LyTestTools.ly_test_tools.report: modules used for reporting
- LyTestTools.tests: LyTestTools integration, unit, and example usage tests
DIRECTORY STRUCTURE
-------------------
The directory structure corresponds to the package structure. For example, the
ly_test_tools.builtin package is located in the ly_test_tools/builtin/ directory.
ENTRY POINTS
------------
Deploying the project in development mode installs only entry points for pytest fixtures.
UNINSTALLATION
--------------
The preferred way to uninstall the project is:
/path/to/your/python -m pip uninstall ly_test_tools
+10
View File
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,59 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
OS and devices are detected and set as constants when ly_test_tools.__init__() completes.
"""
import logging
import sys
logger = logging.getLogger(__name__)
# Supported platforms.
ALL_PLATFORM_OPTIONS = ['android', 'ios', 'linux', 'mac', 'windows']
ALL_LAUNCHER_OPTIONS = ['android', 'base', 'mac', 'windows', 'windows_editor', 'windows_dedicated']
ANDROID = False
IOS = False # Not implemented - see SPEC-2505
LINUX = sys.platform.startswith('linux') # Not implemented - see SPEC-2501
MAC = sys.platform.startswith('darwin')
WINDOWS = sys.platform.startswith('win')
# Detect platforms.
HOST_OS_PLATFORM = 'unknown'
HOST_OS_EDITOR = 'unknown'
HOST_OS_DEDICATED_SERVER = 'unknown'
LAUNCHERS = {}
for launcher_option in ALL_LAUNCHER_OPTIONS:
LAUNCHERS[launcher_option] = None
from ly_test_tools.launchers.platforms.base import Launcher
LAUNCHERS['base'] = Launcher
if WINDOWS:
HOST_OS_PLATFORM = 'windows'
HOST_OS_EDITOR = 'windows_editor'
HOST_OS_DEDICATED_SERVER = 'windows_dedicated'
import ly_test_tools.mobile.android
from ly_test_tools.launchers import AndroidLauncher, WinLauncher, DedicatedWinLauncher, WinEditor
ANDROID = ly_test_tools.mobile.android.can_run_android()
LAUNCHERS['windows'] = WinLauncher
LAUNCHERS['windows_editor'] = WinEditor
LAUNCHERS['windows_dedicated'] = DedicatedWinLauncher
LAUNCHERS['android'] = AndroidLauncher
elif MAC:
HOST_OS_PLATFORM = 'mac'
HOST_OS_EDITOR = NotImplementedError('LyTestTools does not yet support Mac editor')
HOST_OS_DEDICATED_SERVER = NotImplementedError('LyTestTools does not yet support Mac dedicated server')
from ly_test_tools.launchers import MacLauncher
LAUNCHERS['mac'] = MacLauncher
elif LINUX:
logger.warning(f'Linux operating system is currently not supported, LyTestTools only supports Windows and Mac.')
HOST_OS_PLATFORM = 'linux'
HOST_OS_EDITOR = NotImplementedError('LyTestTools does not yet support Linux editor')
HOST_OS_DEDICATED_SERVER = NotImplementedError('LyTestTools does not yet support Linux dedicated server')
else:
logger.warning(f'WARNING: LyTestTools only supports Windows and Mac, got HOST_OS_PLATFORM: "{HOST_OS_PLATFORM}".')
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,84 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Manages logging.
"""
import contextlib
import sys
import logging
_stream_handler = None
_info_file_handler = None
_debug_file_handler = None
def initialize_logging(info_log_path, debug_log_path):
"""
Stub method to centralize logging initialization, redirects everything to STDOUT.
"""
log = logging.getLogger('')
log.setLevel(logging.DEBUG)
# %(asctime)s
formatter = logging.Formatter("%(relativeCreated)s - %(levelname)s - [%(threadName)s] - %(name)s - %(message)s")
# stdout
global _stream_handler
if _stream_handler is None:
_stream_handler = logging.StreamHandler(stream=sys.stdout)
_stream_handler.setLevel(logging.INFO)
_stream_handler.setFormatter(formatter)
log.addHandler(_stream_handler)
global _info_file_handler
if _info_file_handler is None:
_info_file_handler = logging.FileHandler(info_log_path)
_info_file_handler.setLevel(logging.INFO)
_info_file_handler.setFormatter(formatter)
log.addHandler(_info_file_handler)
global _debug_file_handler
if _debug_file_handler is None:
_debug_file_handler = logging.FileHandler(debug_log_path)
_debug_file_handler.setLevel(logging.DEBUG)
_debug_file_handler.setFormatter(formatter)
log.addHandler(_debug_file_handler)
def terminate_logging():
"""
Removes all of the centralized logging handlers that were previously initialized.
"""
log = logging.getLogger('')
global _stream_handler
if _stream_handler is not None:
log.removeHandler(_stream_handler)
_stream_handler = None
global _info_file_handler
if _info_file_handler is not None:
log.removeHandler(_info_file_handler)
_info_file_handler = None
global _debug_file_handler
if _debug_file_handler is not None:
log.removeHandler(_debug_file_handler)
_debug_file_handler = None
@contextlib.contextmanager
def suppress_logging():
""" Use 'with suppress_logging():' to temporarily disable logging."""
logging.disable(logging.CRITICAL)
try:
yield
finally:
logging.disable(logging.NOTSET)
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,346 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Utility class to resolve Lumberyard directory paths & file mappings.
"""
import os
import warnings
from abc import ABCMeta, abstractmethod
from ly_test_tools.lumberyard.asset_processor import ASSET_PROCESSOR_PLATFORM_MAP
def _find_engine_root(initial_path):
# type: (str) -> tuple
"""
Attempts to find the root engine directory to set the values for engine_root and dev_path.
Assumes it exists at or above the provided "initial_path", and not in a separate directory tree
ex. for the directory "C:\\root_dir\\dev\\":
"C:\\root_dir\\" is the engine_root
"C:\\root_dir\\dev\\" is the dev_path
:param initial_path: The initial directory to search for root from
:return: a tuple of 2 strings representing the engine_root and dev_path
"""
root_file = "engineroot.txt"
current_dir = initial_path
# Look upward a handful of levels, before assuming a missing root directory
# Assumes folder structure similar to: engine_root/dev/Tools/.../ly_test_tools/builtin
for _ in range(15):
if os.path.exists(os.path.join(current_dir, root_file)):
# The parent of the directory containing the engineroot.txt is the root directory
engine_root = os.path.abspath(os.path.join(current_dir, os.path.pardir))
dev_path = current_dir
return engine_root, dev_path
# Using an explicit else to avoid aberrant behavior from following filesystem links
else:
current_dir = os.path.abspath(os.path.join(current_dir, os.path.pardir))
raise OSError(f"Unable to find engine root directory. Verify root file '{root_file}' exists")
class AbstractResourceLocator(object):
__metaclass__ = ABCMeta
def __init__(self, build_directory, project):
# type: (str, str) -> AbstractResourceLocator
"""
:param build_directory: The path to the build directory (i.e. <engine_root>/dev/windows_vs2017/bin/profile)
:param project: The game project (i.e. AutomatedTesting or StarterGame)
"""
engine_root, dev_path = _find_engine_root(os.path.abspath(__file__))
self._build_directory = build_directory
self._engine_root = engine_root
self._dev_path = dev_path
self._project = project
self._cache_override = None
self._db_override = None
self._ap_log_root = None
def engine_root(self):
"""
Return root path for this build.
The engine_root path is one level up from dev.
ex. lyengine
:return: engine_root
"""
return self._engine_root
def dev(self):
"""
Returns the path to the dev directory
ex. <engine_root>\\dev
:return: dev_path
"""
return self._dev_path
def third_party(self):
"""
Return path to 3rdParty directory
ex. <engine_root>\\3rdParty
:return: path to 3rdParty
"""
third_party_folder = os.path.join(self._engine_root, '3rdParty')
third_party_txt_file = os.path.join(third_party_folder, '3rdParty.txt')
if not os.path.isfile(third_party_txt_file):
raise FileNotFoundError(
f"3rdParty.txt file not found at third_party_txt_file location: '{third_party_txt_file}' - "
f"Please specify a directory containing the 3rdParty.txt file.")
return third_party_folder
def build(self):
"""
Return path to the build directory.
ex. engine_root/dev/windows_vs2017/bin/profile)
:return: full path to the bin folder
"""
warnings.warn("build() is deprecated; use build_directory()", DeprecationWarning)
return self.build_directory()
def build_directory(self):
"""
Return path to the build directory
ex. engine_root/dev/windows_vs2017/bin/profile)
:return: full path to the bin folder
"""
return self._build_directory
def project(self):
"""
Return path to the project directory
ex. engine_root/dev/SamplesProject
:return: path to <engine_root>/dev/Project
"""
return os.path.join(self.dev(), self._project)
def asset_processor(self):
"""
Return path for the AssetProcessor executable.
ex.
:return: path to <build_directory>/AssetProcessor
"""
return os.path.join(self.build_directory(), 'AssetProcessor')
def asset_processor_batch(self):
""""
Return path for the AssetProcessorBatch compatible with this build platform and configuration
ex. engine_root/dev/mac/bin/profile/AssetProcessorBatch
:return: path to AssetProcessorBatch
"""
return os.path.join(self.build_directory(), 'AssetProcessorBatch')
def editor(self):
"""
Return path to the editor executable compatible with the current build.
ex. engine_root/dev/mac/bin/profile/Editor
:return: path to Editor
"""
return os.path.join(self.build_directory(), "Editor")
def cache(self):
"""
Return path to the cache dir.
:return: path to engine_root/dev/Cache/
"""
return self._cache_override or os.path.join(self.dev(), "Cache")
def asset_db(self):
"""
Return path to the asset db for the current project
:return: path to cache/<project>/assetdb.sqlite
"""
return self._db_override or os.path.join(self.project_cache(), "assetdb.sqlite")
def platform_cache_path(self, platform):
"""
Return path to the cache for the current platform and project outside the game project folder
:return: path to cache/<project>/<platform>
"""
return os.path.join(self.project_cache(), platform)
def asset_cache(self, platform):
"""
Return path to the cache for the current platform and project inside the game project folder
:return: path to cache/<project>/<platform>/<project>
"""
return os.path.join(self.platform_cache_path(platform), self._project)
def asset_catalog(self, platform):
"""
Return path to the asset catalog for the current platform and project
:return: path to cache/<project>/<platform>/<project>/assetcatalog.xml
"""
return os.path.join(self.asset_cache(platform), "assetcatalog.xml")
def set_ap_log_root(self, log_root):
"""
Set or clear an override for AP's log root. Logs folder will appear here.
:return: path to 'logs' dir in <bin dir> folder
"""
self._ap_log_root = log_root
def ap_log_root(self):
"""
Return path to AssetProcessorBatch's log directory using the project bin dir
:return: path where the "logs" folder will be found
"""
return self._ap_log_root or self.build_directory()
def ap_log_dir(self):
"""
Return path to AssetProcessorBatch's log directory using the project bin dir
:return: path to 'logs' dir in <bin dir> folder
"""
return os.path.join(self.ap_log_root(), 'logs')
def ap_job_logs(self):
"""
Return path to AssetProcessorBatch's log directory using the project bin dir
:return: path to 'logs' dir in <bin dir> folder
"""
return os.path.join(self.ap_log_dir(), 'JobLogs')
def ap_batch_log(self):
"""
Return path to AssetProcessorBatch's log file using the project bin dir
:return: path to 'AP_Batch.log' file in <ap_log_dir> folder
"""
return os.path.join(self.ap_log_dir(), 'AP_Batch.log')
def ap_gui_log(self):
"""
Return path to AssetProcessor's log file using the project bin dir
:return: path to 'AP_Gui.log' file in <ap_log_dir> folder
"""
return os.path.join(self.ap_log_dir(), 'AP_Gui.log')
def project_cache(self):
"""
Return path to the current project cache folder
:return: path to engine_root/dev/Cache/<project>
"""
return os.path.join(self.cache(), self._project)
def get_shader_compiler_path(self):
"""
Return path to shader compiler executable
ex. engine_root/dev/windows_vs2019/bin/profile/CrySCompileServer
:return: path to CrySCompileServer executable
"""
return os.path.join(self.build_directory(), 'CrySCompileServer')
def bootstrap_config_file(self):
return os.path.join(self.dev(), 'bootstrap.cfg')
def asset_processor_config_file(self):
return os.path.join(self.dev(), 'AssetProcessorPlatformConfig.ini')
def autoexec_file(self):
return os.path.join(
self.project(),
'autoexec.cfg'
)
def test_results(self):
"""
Return the path to the TestResults directory containing test artifacts.
:return: path to TestResults dir
"""
return os.path.join(self.dev(), "TestResults")
def devices_file(self):
"""
Return the path to the user's devices.ini file. This has OS specific functionality.
Windows: %USERPROFILE%/ly_test_tools/devices.ini
Mac: ~/ly_test_tools/devices.ini
:return: Path to <root>/ly_test_tools/devices.ini
a.k.a. %USERPROFILE%/ly_test_tools/devices.ini
"""
return os.path.join(os.path.expanduser('~'),
'ly_test_tools',
'devices.ini')
def shader_compiler_config_file(self):
"""
Return path to the Shader Compiler config file
ex. engine_root/dev/windows_vs2019/bin/profile/config.ini
:return: path to the Shader Compiler config file
"""
return os.path.join(self.build_directory(), 'config.ini')
def shader_cache(self):
"""
Return path to the shader cache for the current build
ex. engine_root/dev/windows_vs2019/bin/profile/Cache
:return: path to the shader cache for the current build
"""
return os.path.join(self.build_directory(), 'Cache')
#
# The following are OS specific paths and must be defined by an override
#
@abstractmethod
def platform_config_file(self):
"""
Return the path to the platform config file.
:return: path to the platform config file (i.e. engine_root/dev/system_windows_pc.cfg)
"""
raise NotImplementedError(
"platform_config_file() is not implemented on the base AbstractResourceLocator() class. "
"It must be defined by the inheriting class - "
"i.e. _WindowsResourceLocator(AbstractResourceLocator).platform_config_file()")
@abstractmethod
def platform_cache(self):
"""
Return path to the cache for the current operating system platform and project
:return: path to engine_root/dev/cache/<project>/<platform>
"""
raise NotImplementedError(
"platform_cache() is not implemented on the base AbstractResourceLocator() class. "
"It must be defined by the inheriting class - "
"i.e. _WindowsResourceLocator(AbstractResourceLocator).platform_cache()")
@abstractmethod
def project_log(self):
"""
Return path to the project's log dir using the builds project and operating system platform
:return: path to 'log' dir in the platform cache dir
"""
raise NotImplementedError(
"project_log() is not implemented on the base AbstractResourceLocator() class. "
"It must be defined by the inheriting class - "
"i.e. _WindowsResourceLocator(AbstractResourceLocator).project_log()")
@abstractmethod
def project_screenshots(self):
"""
Return path to the project's screen shot dir using the builds project and platform
:return: path to 'screen shot' dir in the platform cache dir
"""
raise NotImplementedError(
"project_screenshots() is not implemented on the base AbstractResourceLocator() class. "
"It must be defined by the inheriting class - "
"i.e. _WindowsResourceLocator(AbstractResourceLocator).project_screenshots()")
@abstractmethod
def editor_log(self):
"""
Return path to the project's editor log dir using the builds project and platform
:return: path to editor.log
"""
raise NotImplementedError(
"editor_log() is not implemented on the base AbstractResourceLocator() class. "
"It must be defined by the inheriting class - "
"i.e. _WindowsResourceLocator(AbstractResourceLocator).editor_log()")
@@ -0,0 +1,184 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Workspace Manager: Provides an API for managing lumberyard installations and file manipulation
"""
import logging
import os
import shutil
import six
import stat
import tempfile
import re
import ly_test_tools.environment.file_system as file_system
logger = logging.getLogger(__name__)
class ArtifactManager(object):
def __init__(self, root):
self.artifact_path = root # i.e.: ~/dev/TestResults/2019-10-15T13_38_42_855000/
self.dest_path = None
self._set_dest_path() # Sets the self.dest_path attribute as the main artifact save path for test files.
def _get_collision_handled_filename(self, file_path, amount=1):
# type: (str, int) -> str
"""
Handles filename collision by appending integers, checking if the file exists, then incrementing if so. Will
increase up to the amount parameter before stopping.
:param file_path: The file path as a string to check for name collisions
:param amount: amount of renames possible for the string if file_path collision occurs by appending integers to
the name. If the amount is reached, the save will override the file instead.
:return: The new file_path as a string
"""
# Name collision handling not needed for 1 name
if amount == 1:
return file_path
# extension will be an empty string if it doesn't exist
file_without_ext, extension = os.path.splitext(file_path)
for i in range(1, amount): # Start at "_1" instead of "_0"
updated_path = f"{file_without_ext}_{i}{extension}"
if not os.path.exists(updated_path):
return updated_path
logger.info(f"Maximum number of attempts: {amount} met when trying to handle name collision for file: "
f"{file_path}. Ending on {updated_path} which will override the existing file.")
return updated_path
def _set_dest_path(self, test_name=None, amount=1):
"""
Sets self.dest_path if not set, and returns the value currently set in self.dest_path. Also creates the
directory if it already doesn't exist.
:param test_name: If set, will update self.dest_path to include the test_name value passed.
:param amount: The amount of folders to create matching self.dest_path and adding an index value to each.
:return: None, but sets the self.dest_path attribute when called.
"""
if test_name:
self.dest_path = os.path.join(self.dest_path, file_system.sanitize_file_name(test_name))
elif not self.dest_path:
self.dest_path = self.artifact_path
# Create unique artifact folder
if not os.path.exists(self.dest_path):
self.dest_path = self._get_collision_handled_filename(self.dest_path, amount)
try:
logger.debug(f'Attempting to create new artifact path: "{self.dest_path}"')
if not os.path.exists(self.dest_path):
os.makedirs(self.dest_path)
logger.info(f'Created new artifact path: "{self.dest_path}"')
return self.dest_path
except (IOError, OSError, WindowsError) as err:
problem = WindowsError(f'Failed to create new artifact path: "{self.dest_path}"')
six.raise_from(problem, err)
def set_test_name(self, test_name=None, amount=1):
"""
Set the test name used to log the artifacts, if set, all artifacts are saved to a subdir named after this test.
This value will get appended to the main path in the self.dest_path attribute.
:param test_name: Name of the test, format of "module_class_method",
i.e.: "test_module_TestClass_test_BasicTestMethod_ValidInputTest_ReturnsTrue_1"
:param amount: int representing the amount of folders to create for test_name
:return: None but updates the self.dest_path attribute with the test name in it.
"""
self._set_dest_path(test_name=test_name, amount=amount)
def generate_folder_name(self, test_module, test_class, test_method):
"""
Takes a test module, class, & method and generates a folder name string with an added
count value to make the name unique for test methods that run multiple times.
Returns the newly generated folder name string.
:param test_module: string for the name of the current test module
:param test_class: string for the name of the current test class
:param test_method: string for the name of the current test method
:return: string for naming a folder that represents the current test, with a maximum length
of 60 trimmed down to match this requirement.
"""
folder_name = file_system.reduce_file_name_length(
file_name="{}_{}_{}".format(test_module, test_class, test_method),
max_length=60)
return folder_name
def save_artifact(self, artifact_path, artifact_name=None, amount=1):
"""
Store an artifact to be logged. Will ensure the new artifact is writable to prevent directory from being
locked later.
:param artifact_path: string representing the full path to the artifact folder.
:param artifact_name: string representing a new artifact name for log if necessary, max length: 25 characters.
:param amount: amount of renames possible for the saved artifact if file name collision occurs by appending
integers to the name. If the amount is reached, the save will override the file instead.
"""
if artifact_name:
artifact_name = file_system.reduce_file_name_length(file_name=artifact_name, max_length=25)
dest_path = os.path.join(self.dest_path,
artifact_name if artifact_name is not None else os.path.basename(artifact_path))
if os.path.exists(dest_path):
dest_path = self._get_collision_handled_filename(dest_path, amount)
logger.debug("Copying artifact from '{}' to '{}'".format(artifact_path, dest_path))
if os.path.isdir(artifact_path):
shutil.copytree(artifact_path, dest_path)
else:
shutil.copy(artifact_path, dest_path)
os.chmod(dest_path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
def generate_artifact_file_name(self, artifact_name):
"""
Returns a string for generating a new artifact file inside of the artifact folder.
:param artifact_name: string representing the name for the artifact file (i.e. "ToolsInfo.log")
:return: string for the full path to the file inside the artifact path even if not valid, i.e.:
~/dev/TestResults/2019-10-14T11_36_12_234000/pytest_results/test_module_TestClass_Method_1/ToolsInfo.log
"""
if not artifact_name:
raise ValueError('artifact_name is a required parameter for generate_artifact_file_name()')
file_path = os.path.join(self.dest_path,
artifact_name)
return file_path
def gather_artifacts(self, destination, format='zip'):
"""
Gather collected artifacts to the specified destination as an archive file (zip by default).
Destination should not contain file extension, the second parameter automatically determines the best extension
to use.
:param destination: where to write the archive file, do not add extension
:param format: archive format, default is 'zip', possible values: tar, gztar and bztar.
:return: full path to the generated archive or raises a WindowsError if shutil.mark_archive() fails.
"""
try:
return shutil.make_archive(destination, format, self.dest_path)
except WindowsError:
logger.exception(
'Windows failed to find the target artifact path: "{}" '
'which may indicate test setup failed.'.format(self.dest_path))
class NullArtifactManager(ArtifactManager):
"""
An ArtifactManager that ignores all calls, used when logging is not configured.
"""
def __init__(self):
# The null ArtifactManager redirects all calls to a temp dir
super(NullArtifactManager, self).__init__(tempfile.mkdtemp())
def _get_collision_handled_filename(self, artifact_path=None, amount=None):
raise NotImplementedError("Attempt was made to create artifact save paths through NullArtifactManager.")
def save_artifact(self, artifact, artifact_name=None, amount=None):
return None
def gather_artifacts(self, destination, format='zip'):
return None
@@ -0,0 +1,62 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Holds functions for killing pre-existing Lumberyard processes before a test.
"""
import logging
import ly_test_tools.builtin.helpers
import ly_test_tools.environment.process_utils
LY_PROCESS_KILL_LIST = ly_test_tools.environment.process_utils.LY_PROCESS_KILL_LIST # copy to preserve interface
logger = logging.getLogger(__name__)
class LyProcessKillerException(Exception):
"""Custom exception class for this file."""
pass
def detect_lumberyard_processes(processes_list):
# type: (str or list) -> list
"""
Parses the strings in the processes_list.
Process names must not include file extensions or they will not be found.
:param processes_list: str or list of strings representing the Lumberyard processes to search for.
:return: list of all detected processes parsed from processes_list.
"""
processes_detected = []
if type(processes_list) is str:
processes_list = [processes_list]
for process_to_detect in processes_list:
logger.debug('Checking if process named "{}" is running.'.format(process_to_detect))
if ly_test_tools.environment.process_utils.process_exists(name=process_to_detect, ignore_extensions=True):
logger.debug('Detected process: "{}" is running.'.format(process_to_detect))
processes_detected.append(process_to_detect)
return processes_detected
def kill_processes(processes_list):
# type: (list) -> None
"""
Kills Lumberyard processes by name (without file extensions included).
:param processes_list: list of strings representing process names to kill
:return: None.
"""
if type(processes_list) is not list:
raise LyProcessKillerException('processes_list must be of type "list" for the kill_processes() function.')
logger.info('Killing list of processes by name: {}'.format(processes_list))
ly_test_tools.environment.process_utils.kill_processes_named(names=processes_list, ignore_extensions=True)
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,88 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Inside this module are 2 classes used for Mac directory & workspace mappings:
1. _MacResourceLocator(AbstractResourceLocator) derived class.
2. MacWorkspaceManager(AbstractWorkspaceManager) derived class.
"""
import os
import logging
from ly_test_tools._internal.managers.workspace import AbstractWorkspaceManager
from ly_test_tools._internal.managers.abstract_resource_locator import AbstractResourceLocator
logger = logging.getLogger(__name__)
CACHE_DIR = 'osx_gl'
CONFIG_FILE = 'system_osx_osx_gl.cfg'
class _MacResourceLocator(AbstractResourceLocator):
"""
Override for locating resources in a Mac operating system running LyTestTools.
"""
def platform_config_file(self):
"""
Return the path to the platform config file.
ex. engine_root/dev/system_osx_osx_gl.cfg
:return: path to the platform config file
"""
return os.path.join(self.dev(), CONFIG_FILE)
def platform_cache(self):
"""
Return path to the cache for the Mac operating system.
:return: path to cache for the Mac operating system
"""
return os.path.join(self.project_cache(), CACHE_DIR)
def project_log(self):
"""
Return path to the project's log dir for the Mac operating system.
:return: path to 'log' dir in the platform cache dir
"""
return os.path.join(self.platform_cache(), 'user', 'log')
def project_screenshots(self):
"""
Return path to the project's screenshot dir for the Mac operating system.
:return: path to 'screenshot' dir in the platform cache dir
"""
return os.path.join(self.platform_cache(), 'user', 'ScreenShots')
def editor_log(self):
"""
Return path to the project's editor log dir using the builds project and platform
:return: path to editor.log
"""
return os.path.join(self.project_log(), "editor.log")
class MacWorkspaceManager(AbstractWorkspaceManager):
"""
A Mac host WorkspaceManager. Contains Mac overridden functions for the AbstractWorkspaceManager class.
Also creates a Mac host ResourceLocator for directory and build mappings.
"""
def __init__(
self,
build_directory=None,
project=None,
tmp_path=None,
output_path=None,
):
# Type: (str,str,str,str) -> None
super(MacWorkspaceManager, self).__init__(
_MacResourceLocator(build_directory, project),
project=project,
tmp_path=tmp_path,
output_path=output_path,
)
@@ -0,0 +1,106 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Inside this module are 2 classes used for Windows directory & workspace mappings:
1. _WindowsResourceLocator(AbstractResourceLocator) derived class.
2. WindowsWorkspaceManager(AbstractWorkspaceManager) derived class.
"""
import logging
import os
import sys
import ly_test_tools.environment.process_utils
import ly_test_tools.environment.reg_cleaner
import ly_test_tools.environment.waiter
import ly_test_tools.launchers.exceptions
from ly_test_tools._internal.managers.abstract_resource_locator import AbstractResourceLocator
from ly_test_tools._internal.managers.workspace import AbstractWorkspaceManager
logger = logging.getLogger(__name__)
CACHE_DIR = 'pc'
CONFIG_FILE = 'system_windows_pc.cfg'
class _WindowsResourceLocator(AbstractResourceLocator):
"""
Override for locating resources in a Windows operating system running LyTestTools.
"""
def platform_config_file(self):
"""
Return the path to the platform config file.
ex. engine_root/dev/system_osx_osx_gl.cfg
:return: path to the platform config file
"""
return os.path.join(self.dev(), CONFIG_FILE)
def platform_cache(self):
"""
Return path to the cache for the Windows operating system.
:return: path to cache for the Windows operating system
"""
return os.path.join(self.project_cache(), CACHE_DIR)
def project_log(self):
"""
Return path to the project's log dir for the Windows operating system.
:return: path to 'log' dir in the platform cache dir
"""
return os.path.join(self.platform_cache(), 'user', 'log')
def project_screenshots(self):
"""
Return path to the project's screenshot dir for the Windows operating system.
:return: path to 'screenshot' dir in the platform cache dir
"""
return os.path.join(self.platform_cache(), 'user', 'ScreenShots')
def editor_log(self):
"""
Return path to the project's editor log dir using the builds project and platform
:return: path to editor.log
"""
return os.path.join(self.project_log(), "editor.log")
class WindowsWorkspaceManager(AbstractWorkspaceManager):
"""
A Windows host WorkspaceManager. Contains Windows overridden functions for the AbstractWorkspaceManager class.
Also creates a Windows host ResourceLocator.
"""
def __init__(
self,
build_directory=None,
project=None,
tmp_path=None,
output_path=None,
):
# Type: (str,str,str,str,str) -> None
super(WindowsWorkspaceManager, self).__init__(
_WindowsResourceLocator(build_directory, project),
project=project,
tmp_path=tmp_path,
output_path=output_path,
)
def set_registry_keys(self):
"""
Set the certain registry flags that are required to be set before compilation will succeed.
"""
if sys.platform == 'win32':
ly_test_tools.environment.reg_cleaner.create_ly_keys()
def clear_settings(self):
logger.debug("Build::setup_clear_registry")
if sys.platform == "win32":
ly_test_tools.environment.reg_cleaner.clean_ly_keys(exception_list=r"SOFTWARE\Amazon\Lumberyard\Identity")
@@ -0,0 +1,175 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Workspace Manager: Provides an API for managing lumberyard installations and saving of files
"""
import abc
import datetime
import logging
import os
import subprocess
import tempfile
import ly_test_tools.environment.file_system
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.lumberyard.asset_processor
import ly_test_tools.lumberyard.settings as settings
import ly_test_tools.lumberyard.shader_compiler
import ly_test_tools._internal.managers.artifact_manager as artifact_manager
import ly_test_tools._internal.managers.abstract_resource_locator as arl
logger = logging.getLogger(__name__)
class AbstractWorkspaceManager:
__metaclass__ = abc.ABCMeta
"""
Base workspace manager: provides a simple managed setup/teardown.
All workspace managers are subclasses of this.
"""
def __init__(self, # type: AbstractWorkspaceManager
resource_locator, # type: arl.AbstractResourceLocator
project, # type: str
tmp_path=None, # type: str or None
output_path=None, # type: str or None
): # type: (...) -> None
"""
Create a workspace manager with an associated AbstractResourceLocator object and initialize temp and logs dirs.
The workspace contains information about the workspace being used and the running pytest test.
:param resource_locator: A resource locator to create paths for the workspace
:param project: Lumberyard project to use for the LumberyardRelease object
:param tmp_path: A path to use for storing temp files, if not specified default to the system's tmp
:param output_path: A path used to store artifacts, if not specified defaults to
"<build>\\dev\\TestResults\\<timestamp>"
"""
self.paths = resource_locator
self.project = project
self.artifact_manager = artifact_manager.NullArtifactManager()
self.asset_processor = ly_test_tools.lumberyard.asset_processor.AssetProcessor(self)
self.shader_compiler = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(self)
self._original_cwd = os.getcwd()
self.tmp_path = tmp_path
self.output_path = output_path
if not self.tmp_path:
self.tmp_path = tempfile.mkdtemp()
self.settings = settings.LySettings(self.tmp_path, self.paths)
if not self.output_path:
self.output_path = os.path.join(self.paths.test_results(),
datetime.datetime.now().strftime('%Y-%m-%dT%H_%M_%S_%f'))
logger.info(f"No logs path set, using default based on timestamp: {self.output_path}")
def setup(self):
"""
Perform default setup for this workspace. Configures tmp and logs path, loggers
Derived classes should call this before calling its own setup code (Unless you really know what you are doing).
:return: None
"""
if self.tmp_path:
print(f"Checking for tmp path {self.tmp_path}")
if os.path.exists(self.tmp_path):
print("Found existing tmp path, deleting")
ly_test_tools.environment.file_system.delete([self.tmp_path], True, True)
print("Creating tmp path")
os.makedirs(self.tmp_path)
if not os.path.exists(self.output_path):
print(f"Creating logs path at {self.output_path}")
os.makedirs(self.output_path)
print(f"Configuring Artifact Manager with path {self.output_path}")
self.artifact_manager = artifact_manager.ArtifactManager(self.output_path)
def teardown(self):
"""
Perform teardown on this workspace: call teardown() on the LumberyardRelease object and delete tmp_path.
Derived classes should call this after calling its own teardown code (Unless you really know what you are doing)
:return: None
"""
logger.debug("Deleting tmp path")
os.chdir(self._original_cwd)
if self.tmp_path:
ly_test_tools.environment.file_system.delete([self.tmp_path], True, True)
def clear_cache(self):
"""
Clears the Cache folder located at dev/Cache
:return: None
"""
if os.path.exists(self.paths.cache()):
logger.info(f"Clearing {self.paths.cache()}")
ly_test_tools.environment.file_system.delete([self.paths.cache()], True, True)
return
logger.info(f"Cache directory: {self.paths.cache()} could not be found.")
def clear_bin(self):
"""
Clears the relative Bin folder (i.e. engine_root/dev/windows_vs2019/bin/profile/)
:return: None
"""
if os.path.exists(self.paths.build_directory()):
logger.info(f"Clearing {self.paths.build_directory()}")
ly_test_tools.environment.file_system.delete([self.paths.build_directory()], True, True)
return
logger.info(f"build_directory directory: {self.paths.build_directory()} could not be found.")
def _execute_and_save_log(self, command, log_file_name):
"""
Executes a subprocess command and saves its output with the artifacts of current test
:param command: command to execute
:param log_file_name: artifact name to save
:raises subprocess.CalledProcessError, OSError: on command failure
"""
temp_file_dir = os.path.join(tempfile.gettempdir(), "LyTestTools")
temp_file_path = os.path.join(temp_file_dir, log_file_name)
if not os.path.exists(temp_file_dir):
os.makedirs(temp_file_dir)
if os.path.exists(temp_file_path):
# assume temp is not being used for long-term storage, and safe to delete
os.remove(temp_file_path)
try:
with open(temp_file_path, "w+") as logfile:
try:
output = process_utils.check_output(command, stderr=subprocess.STDOUT)
logfile.write(output)
except subprocess.CalledProcessError as err:
failure_output = err.output if err.output else "<no output>"
logger.exception(
f'Command "{command}" failed with exit code "{err.returncode}" '
f'and output: {failure_output.decode()}')
logfile.write(failure_output.decode())
raise
finally:
self.artifact_manager.save_artifact(temp_file_path)
except OSError:
logger.exception(f'Command "{command}" failed due to a filesystem error.')
raise
finally:
if os.path.exists(temp_file_path):
try:
os.remove(temp_file_path)
except Exception: # purposefully broad
logger.warning(
f"Ignored exception while cleaning up file: {temp_file_dir}", exc_info=True)
@@ -0,0 +1,15 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
# Stores LyTT variables configured via pytest CLI plugin, for later access without dependency on fixtures
# these values should only be modified during initialization, or patched during unit tests
build_directory = None
output_path = None
@@ -0,0 +1,152 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Adds a hook for the @pytest.mark.test_case_id decorator which allows users to mark
tests with test case IDs that show up on pytest .xml reports.
Additionally, users can use the "--test-case-ids" CLI argument to only run the
comma-separated test case IDs listed after the arg.
"""
import logging
import pytest
import six
ID_MARKER = "test_case_id"
log = logging.getLogger(__name__)
class TestCaseIDException(Exception):
"""Raised when "--test-case-ids" CLI arg is invalid."""
pass
def pytest_configure(config):
"""
Pytest configuration for @pytest.mark.test_case_id markers.
"""
config.addinivalue_line(
'markers',
'test_case_id: Add test case ID to test.',
)
def pytest_addoption(parser):
"""
Will cause only the comma-separated list of test case IDs
listed after '--test-case-ids' to be run.
:param parser: pytest-provided fixture for argparse.ArgumentParser
"""
parser.addoption("-I", "--test-case-ids", nargs='?',
help="Only run tests marked with the specified ID(s).")
def _pytest_runtest_makereport_imp(report, item):
try:
test_case_id_marker = item.get_marker(ID_MARKER)
except AttributeError: # use get_closest_marker() instead
test_case_id_marker = item.get_closest_marker(ID_MARKER)
if report.when == 'call' and test_case_id_marker is not None:
test_case_ids = _parse_test_case_ids(test_case_id_marker.args)
try:
xml_report = getattr(item.config, '_xml')
except AttributeError:
log.warning('No .xml report found in test, skipping pytest reporting hooks.')
return
for test_case_id in test_case_ids:
report_node = xml_report.node_reporter(report.nodeid)
report_node.add_property('test_case_id', test_case_id)
@pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_runtest_makereport(item, call):
"""
Modify the pytest jUnitXML report to add test case IDs.
Tests require the @pytest.mark.test_case_id decorator
in order to have the test case ID appear on the report.
:param item: Test in the current pytest runner session.
:param call: Call in the current pytest runner session.
"""
outcome = yield
report = outcome.get_result()
_pytest_runtest_makereport_imp(report, item)
def pytest_collection_modifyitems(items, config):
"""
Reduces test collection to tests with the 'test_case_id'
specified by the user using the --test-case-ids CLI arg.
:param items: All tests within the pytest runner session.
:param config: Call config for pytest runner session.
"""
id_filtering_enabled = config.option.test_case_ids
selected_items = set()
deselected_items = set()
if id_filtering_enabled:
filtered_test_case_ids = _parse_test_case_ids(
id_filtering_enabled.split(','))
for item in items:
try:
test_case_id_marker = item.get_marker(ID_MARKER)
except AttributeError:
log.debug('item.get_marker() call failed, using item.get_closest_marker() instead.')
test_case_id_marker = item.get_closest_marker(ID_MARKER)
test_case_ids = _parse_test_case_ids(test_case_id_marker.args)
if _any_exist_in(test_case_ids, filtered_test_case_ids):
selected_items.add(item)
else:
deselected_items.add(item)
config.hook.pytest_deselected(items=deselected_items)
items[:] = selected_items
def _any_exist_in(select_from, find_in):
"""
:param select_from: iterable keys to find
:param find_in: iterable to search in
:return: True if any item in the first iterable exists in the second
"""
for target in select_from:
if target in find_in:
return True
return False
def _parse_test_case_ids(test_case_ids):
"""
Return a set representing unique test case ID values
:param test_case_ids: list or tuple containing test case ID values (strings or ints)
:return: set of unique values
"""
parsed_ids = set()
for test_case_id in test_case_ids:
if type(test_case_id) == int:
parsed_ids.add(test_case_id)
else:
try: # dedupe strings which are identical to integers
target_id = int(test_case_id)
except ValueError as err:
log.debug(err)
if type(test_case_id) == str and len(test_case_id.strip()) > 0:
target_id = test_case_id # valid string
else:
problem = TestCaseIDException('Invalid test_case_id detected: "{}", test_case_id must be of type '
'str or int.'.format(test_case_id))
six.raise_from(problem, err)
parsed_ids.add(target_id)
return parsed_ids
@@ -0,0 +1,83 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import os
import sys
from ly_test_tools import WINDOWS
def _get_test_launcher_cmd():
"""
Helper function to determine the test launcher command for the current platform
:return: The test launcher command
"""
python_runner = "python3.cmd"
if not WINDOWS:
python_runner = "python3.sh"
current_dir = sys.executable
# Look upward a handful of levels to check for the LY python entry point script
# Assumes folder structure similar to: /Python/python.cmd
for _ in range(10):
python_wrapper = os.path.join(current_dir, python_runner)
if os.path.exists(python_wrapper):
return f"{python_wrapper} -m pytest "
# Using an explicit else to avoid aberrant behavior from following filesystem links
else:
current_dir = os.path.abspath(os.path.join(current_dir, os.path.pardir))
return f"{sys.executable} -m pytest "
def _format_cmd(launcher_cmd, test_path, nodeid):
"""
Builds a command with required arguments to run a test
:param launcher_cmd: The test launcher command
:param test_path: File or directory that contains the test(s) that were run
:param nodeid: A test node id, with parametrized values
:return: Formatted command to re-run a test with parametrized values
"""
# Assign the test id argument accordingly:
# the node id (with parameters) is already part of the test path argument
# when a single test case was invoked or
# the node id (with parameters) includes the test filename when a whole
# test module was invoked else
# append the nodeId to the path when a whole test directory was invoked
if nodeid == os.path.split(test_path)[-1]:
test_id_argument = test_path
elif os.path.split(test_path)[-1] in nodeid:
test_id_argument = os.path.abspath(
os.path.join(os.path.dirname(test_path), nodeid))
else:
test_id_argument = os.path.abspath(
os.path.join(test_path, os.path.normpath(nodeid)))
return f"{launcher_cmd}{test_id_argument}"
def build_rerun_commands(test_path, nodeids):
"""
Builds a list of commands to run tests
:param test_path: File or directory that contains the test(s) that were run
:param nodeids: List of test node ids, with parametrized values
:return: A list of commands to re-run tests
"""
commands = []
test_launcher_cmd = _get_test_launcher_cmd()
for nodeid in nodeids:
commands.append(_format_cmd(test_launcher_cmd, test_path, nodeid))
return commands
@@ -0,0 +1,74 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import os
import ly_test_tools._internal.pytest_plugin.failed_test_rerun_command as rerun
def _add_commands(terminalreporter, header, test_path, nodeids):
"""
Add test re-run commands to the TerminalReporter object
:param terminalreporter: Pytest's TerminalReporter object that contains test result information.
:param header: Message to write to TerminalReporter before list is added
:param test_path: File or directory that contains the test(s) that to run
:param nodeids: List of test node ids, with parametrized values
"""
terminalreporter.write_line(header)
if nodeids:
commands = rerun.build_rerun_commands(test_path, nodeids)
for command in commands:
terminalreporter.write_line(command)
else:
terminalreporter.write_line("Error, Test node id list is empty!")
def pytest_terminal_summary(terminalreporter, exitstatus):
"""
Pytest's hook for terminal reporting. This hook is invoked at the end of the test session.
:param terminalreporter: Pytest's TerminalReporter object that contains test result information.
:param exitstatus: Exit that will be returned to the system
"""
# Add to the TerminalReport a section for failed test re-running
failures = terminalreporter.stats.get('failed', [])
errors = terminalreporter.stats.get('error', [])
failure_count = len(failures)
error_count = len(errors)
if failure_count or error_count:
file_or_dir_option = terminalreporter.config.getoption('file_or_dir', default=[])
if file_or_dir_option:
test_path = file_or_dir_option[0]
else:
test_path = ''
terminalreporter.section("Test failure and error troubleshooting")
if failure_count:
nodeids = [os.path.basename(report.nodeid) for report in failures]
_add_commands(
terminalreporter,
"Use the following commands to re-run each test that failed locally\n"
"(NOTE: The 'PYTHON' or 'PYTHONPATH' environment variables need values for accurate commands): ",
test_path, nodeids)
if error_count:
nodeids = [os.path.basename(report.nodeid) for report in errors]
_add_commands(
terminalreporter,
"Use the following commands to re-run each test that had errors locally\n"
"(NOTE: The 'PYTHON' or 'PYTHONPATH' environment variables need values for accurate commands): ",
test_path, nodeids)
@@ -0,0 +1,438 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Generic fixtures bundled with LyTestTools
These fixtures will be available to the end user without requiring to import any file.
"""
import getpass
import logging
import os
import socket
import time
from datetime import datetime
import pytest
import ly_test_tools._internal.pytest_plugin
import ly_test_tools._internal.log.py_logging_util as py_logging_util
import ly_test_tools._internal.managers.ly_process_killer as ly_process_killer
import ly_test_tools.builtin.helpers as helpers
import ly_test_tools.environment.file_system
import ly_test_tools.launchers.launcher_helper
import ly_test_tools.launchers.platforms.base
import ly_test_tools.environment.watchdog
from ly_test_tools import ALL_PLATFORM_OPTIONS, HOST_OS_PLATFORM
logger = logging.getLogger(__name__)
TIMESTAMP_FORMAT = '%Y-%m-%dT%H-%M-%S-%f' # ISO with colon and period replaced to dash
TOOLS_INFO_LOG_NAME = "ToolsInfo.log"
TOOLS_DEBUG_LOG_NAME = "ToolsDebug.log"
def pytest_addoption(parser):
"""
Adds CLI options to launch with pytest.
Pytest will find and visit this function during its initialization
:param parser: pytest-provided fixture for argparse.ArgumentParser
"""
parser.addoption("--output-path",
help="A folder for test artifacts and logs")
parser.addoption("--build-directory", nargs='?', default='',
help="An existing CMake binary output directory which contains the lumberyard executables,"
"such as: D:/ly/dev/windows_vs2017/bin/profile/")
def pytest_configure(config):
"""
Save custom CLI options during Pytest configuration, so they are later accessible without using fixtures
Pytest will find and visit this function after initialization
:param config: pytest-provided fixture for configured arguments
"""
ly_test_tools._internal.pytest_plugin.build_directory = _get_build_directory(config)
ly_test_tools._internal.pytest_plugin.output_path = _get_output_path(config)
def _get_build_directory(config):
"""
Fetch and verify the cmake build directory CLI arg, without creating an error when unset
:param config: pytest config object
"""
custom_build_directory = config.getoption('--build-directory', '')
if custom_build_directory:
logger.debug(f'Custom build directory set via cli arg to: {custom_build_directory}')
if not os.path.exists(custom_build_directory):
raise ValueError(f'Pytest argument "--build-directory" does not exist at: {custom_build_directory}')
else:
# only warn when unset, allowing non-LyTT tests to still use pytest
logger.warning(f'Pytest argument "--build-directory" was not provided, tests using LyTestTools will fail')
return custom_build_directory
def _get_output_path(config):
"""
Fetch and verify the CLI arg for the path where tests artifacts are saved, without creating an error when unset
:param config: pytest config object
"""
custom_output_path = config.getoption("--output-path")
if custom_output_path:
logger.debug(f'Custom output_path set to: {str(custom_output_path)}')
output_path = custom_output_path
else:
# from pytest_runner
output_path = os.path.join(os.getcwd(),
"TestResults",
datetime.now().strftime(TIMESTAMP_FORMAT),
"pytest_results")
logger.debug(f'Defaulting output_path to: {str(output_path)}')
os.makedirs(output_path, exist_ok=True)
return output_path
@pytest.fixture(scope="session")
def record_suite_property(request):
"""
Sets a global property at the Suite level in pytest's internal report, adding it if it does not yet exist else
overwriting all current values.
These properties become part of the test report and are available to the configured reporters, e.g. JUnit XML.
The fixture is callable with ``(name, value)``, with value being automatically xml-encoded.
Example::
def test_function(record_suite_property):
record_suite_property("example_key", 1)
"""
return _record_suite_property(request)
def _record_suite_property(request):
"""Separate implementation to call directly during unit tests"""
xml = getattr(request.config, "_xml", None)
if xml is not None:
def set_prop(name, value):
property_exists = [propname for propname in xml.global_properties if propname[0] == name]
if property_exists:
for prop in range(len(xml.global_properties)):
if xml.global_properties[prop][0] == name:
xml.global_properties[prop] = name, value
else:
xml.add_global_property(name, value)
return set_prop
else:
def set_prop_noop(name, value):
logger.debug(
f"Pytest junitxml unexpectedly not configured, unable to add global suite property '{name}' "
f"with value '{value}'")
return set_prop_noop
@pytest.fixture(scope="session")
def output_path(request):
"""
Returns the desired name for the folder that will store the results of the tests. If no name is provided, it will
default to naming the folder after the timestamp at the moment this test session started. Datetime as string in the
format YYYY-MM-DDTHH_MM_SS_F This fixture is useful to have an unique ID for the current session.
:return: custom logs path, defaulting to current timestamp
"""
return ly_test_tools._internal.pytest_plugin.output_path
@pytest.fixture
def build_directory(request):
return ly_test_tools._internal.pytest_plugin.build_directory
@pytest.fixture
def record_build_name(record_suite_property):
"""
Updates build name in the pytest XML results
:return: function which accepts parameter build_name
"""
return _record_build_name(record_suite_property)
def _record_build_name(record_suite_property):
"""Separate implementation to call directly during unit tests"""
return lambda build_name: record_suite_property("build", build_name)
@pytest.fixture(autouse=True)
def record_test_timestamp(record_property):
"""
Adds a timestamp to the current test in the XML report
"""
return _record_test_timestamp(record_property)
def _record_test_timestamp(record_property):
"""Separate implementation to call directly during unit tests"""
record_property("timestamp", datetime.now().strftime(TIMESTAMP_FORMAT))
@pytest.fixture(scope="session", autouse=True)
def record_suite_data(record_suite_property):
"""
Sets default suite information for pytest's XML output
"""
return _record_suite_data(record_suite_property)
def _record_suite_data(record_suite_property):
"""Separate implementation to call directly during unit tests"""
record_suite_property("timestamp", str(datetime.now().strftime(TIMESTAMP_FORMAT)))
record_suite_property("hostname", str(socket.gethostname()))
record_suite_property("username", str(getpass.getuser()))
@pytest.fixture(scope="function")
def editor(request, workspace, crash_log_watchdog):
# type: (...) -> ly_test_tools.launchers.platforms.base.Launcher
return _editor(
request=request,
workspace=workspace,
launcher_platform=get_fixture_argument(request, 'launcher_platform', HOST_OS_PLATFORM))
def _editor(request, workspace, launcher_platform):
"""Separate implementation to call directly during unit tests"""
editor = ly_test_tools.launchers.launcher_helper.create_editor(workspace, launcher_platform)
def teardown():
editor.stop()
request.addfinalizer(teardown)
return editor
def get_fixture_argument(request, argument_name, default_value):
if argument_name in request.fixturenames:
return request.getfixturevalue(argument_name)
return default_value
@pytest.fixture(scope="function")
def launcher(request, workspace, crash_log_watchdog):
# type: (...) -> ly_test_tools.launchers.platforms.base.Launcher
return _launcher(
request=request,
workspace=workspace,
launcher_platform=get_fixture_argument(request, 'launcher_platform', HOST_OS_PLATFORM),
level=get_fixture_argument(request, 'level', ''))
def _launcher(request, workspace, launcher_platform, level=""):
"""Separate implementation to call directly during unit tests"""
if not level:
launcher = ly_test_tools.launchers.launcher_helper.create_launcher(
workspace, launcher_platform)
else:
launcher = ly_test_tools.launchers.launcher_helper.create_launcher(
workspace, launcher_platform, ['+map', level])
def teardown():
launcher.stop()
request.addfinalizer(teardown)
return launcher
@pytest.fixture(scope="function")
def dedicated_launcher(request, workspace, crash_log_watchdog):
# type: (...) -> ly_test_tools.launchers.platforms.base.Launcher
return _dedicated_launcher(
request=request,
workspace=workspace,
launcher_platform=get_fixture_argument(request, 'launcher_platform', HOST_OS_PLATFORM),
level=get_fixture_argument(request, 'level', ''))
def _dedicated_launcher(request, workspace, launcher_platform, level=""):
"""Separate implementation to call directly during unit tests"""
if not level:
launcher = ly_test_tools.launchers.launcher_helper.create_dedicated_launcher(
workspace, launcher_platform)
else:
launcher = ly_test_tools.launchers.launcher_helper.create_dedicated_launcher(
workspace, launcher_platform, ['+map', level])
def teardown():
launcher.stop()
request.addfinalizer(teardown)
return launcher
@pytest.fixture
def automatic_process_killer(request):
# type: (_pytest.fixtures.SubRequest) -> ly_process_killer
"""
Automatically kills existing Lumberyard processes before a test.
Relies on parametrizing "processes_to_kill" with a list of process names to kill.
If no "processes_to_kill" is found, it will default to ly_process_killer.LY_PROCESS_KILL_LIST instead.
:param request: _pytest.fixtures.SubRequest request object.
:return: ly_process_killer module.
"""
processes_to_kill = get_fixture_argument(request, 'processes_to_kill', ly_process_killer.LY_PROCESS_KILL_LIST)
return _automatic_process_killer(processes_to_kill)
def _automatic_process_killer(processes_to_kill):
# type: (list) -> ly_process_killer
"""
Separate implementation to call directly during unit tests.
"""
# Detect processes.
processes_detected = ly_process_killer.detect_lumberyard_processes(processes_list=processes_to_kill)
# Kill processes.
ly_process_killer.kill_processes(processes_list=processes_detected)
return ly_process_killer
@pytest.fixture(scope="function")
def workspace(request, # type: _pytest.fixtures.SubRequest
build_directory, # type: str
project, # type: str
record_property, # type: _pytest.junitxml.record_property
record_build_name, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.record_build_name
output_path, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.output_path
asset_processor_platform, # type: str
):
# type: (...) -> ly_test_tools._internal.managers.workspace.WorkspaceManager
"""
Create a new platform-specific workspace manager for the current lumberyard build, and configure log reporting.
Expects that Lumberyard has already been built for the target platform, configuration, project, and spec
:param request: _pytest.fixtures.SubRequest request object
:param build_directory: path to the build directory
:param project: Project name to use
:param record_property: PyTest record_property fixture
:param record_build_name: LyTestTools record_build_name fixture
:param output_path: LyTestTools output_path fixture
:param asset_processor_platform: name of the platform to target for the AssetProcessor
:return: A fully configured workspace manager
"""
return _workspace(
request, build_directory, project, record_property, record_build_name, output_path, asset_processor_platform)
def _workspace(request, # type: _pytest.fixtures.SubRequest
build_directory, # type: str
project, # type: str
record_property, # type: _pytest.junitxml.record_property
record_build_name, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.record_build_name
output_path, # type: ly_test_tools._internal.pytest_plugin.test_tools_fixtures.output_path
asset_processor_platform, # type: str
):
"""Separate implementation to call directly during unit tests"""
workspace = helpers.create_builtin_workspace(
build_directory=build_directory,
project=project,
output_path=output_path,
)
# Build names for test artifact folders:
test_module = request.node.module.__name__.split('.')[-1]
test_class = request.node.getmodpath().split('.')[0]
test_method = request.node.originalname
test_name = workspace.artifact_manager.generate_folder_name(
test_module, test_class, test_method)
def teardown():
log_name = f"{test_name}-logs"
log_path = os.path.join(workspace.artifact_manager.artifact_path, log_name)
record_build_name(HOST_OS_PLATFORM)
path = os.path.basename(workspace.artifact_manager.gather_artifacts(log_path))
record_property("log", path)
py_logging_util.terminate_logging()
workspace.artifact_manager.set_test_name() # Reset log name for this test
helpers.teardown_builtin_workspace(workspace)
request.addfinalizer(teardown)
artifact_folder_count = request.session.testscollected # Amount of folders to create for test_name.
helpers.setup_builtin_workspace(workspace, test_name, artifact_folder_count)
# Must be called after helpers.setup_builtin_workspace() above:
info_log_path = workspace.artifact_manager.generate_artifact_file_name(TOOLS_INFO_LOG_NAME)
debug_log_path = workspace.artifact_manager.generate_artifact_file_name(TOOLS_DEBUG_LOG_NAME)
py_logging_util.initialize_logging(info_log_path, debug_log_path)
# Bind the newly created log files to the workspace.
workspace.info_log_path = info_log_path
workspace.debug_log_path = debug_log_path
workspace.asset_processor_platform = asset_processor_platform
return workspace
@pytest.fixture(scope="function")
def crash_log_watchdog(request, workspace):
# type: (...) -> ly_test_tools.environment.watchdog.CrashLogWatchdog
return _crash_log_watchdog(request, workspace, get_fixture_argument(request, 'raise_on_crash', True))
def _crash_log_watchdog(request, workspace, raise_on_crash):
"""Separate implementation to call directly during unit tests"""
error_log = os.path.join(workspace.paths.project_log(), 'error.log')
crash_log_watchdog = ly_test_tools.environment.watchdog.CrashLogWatchdog(
error_log, raise_on_condition=raise_on_crash)
def teardown():
# stop() will either raise an exception or log an error if watchdog has found an error log
crash_log_watchdog.stop()
request.addfinalizer(teardown)
crash_log_watchdog.start()
return crash_log_watchdog
@pytest.fixture(scope="function",
params=[HOST_OS_PLATFORM])
def asset_processor_platform(request):
"""
Platform to target for the AssetProcessor for modifying AssetProcessorPlatformConfig.ini
:param request: _pytest.fixtures.SubRequest request object.
:return: string representing the AssetProcessor platform to use.
"""
return _asset_processor_platform(request)
def _asset_processor_platform(request):
"""Separate implementation to call directly during unit tests"""
ap_platform = request.param
if ap_platform in ALL_PLATFORM_OPTIONS:
logger.debug(f'Returning asset_processor_platform: "{ap_platform}".')
return ap_platform
else:
raise ValueError(
f'asset_processor_platform: "{ap_platform}" is not valid. '
f'Please select from one of the following: {ALL_PLATFORM_OPTIONS}')
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,123 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Helper file for assisting in building workspaces and setting up LTT with the current Lumberyard environment.
"""
import ly_test_tools._internal.pytest_plugin as pytest_plugin
import ly_test_tools._internal.managers.workspace as internal_workspace
from ly_test_tools import MAC, WINDOWS
import os, stat
def create_builtin_workspace(
build_directory=None, # type: str
project="AutomatedTesting", # type: str
tmp_path=None, # type: str or None
output_path=None, # type: str or None
):
# type: (...) -> internal_workspace.AbstractWorkspaceManager
"""
Create a new platform-specific workspace manager for the current lumberyard build
:param build_directory: Custom path to the build directory (i.e. engine_root/dev/windows_vs2017/bin/profile)
when set to None (default) it will use the value configured by pytest CLI argument --build-directory
:param project: Project name to use
:param tmp_path: Path to use as temporal storage, if not specified use default
:param output_path: Path to use as log storage, if not specified use default
:return: A workspace manager that works with the current lumberyard instance
"""
if not build_directory:
if pytest_plugin.build_directory:
# cannot set argument default (which executes on import), must set here after pytest starts executing
build_directory = pytest_plugin.build_directory
else:
raise ValueError(
"Cmake build directory was not set via commandline arguments and not overridden. Please specify with "
r"CLI argument --build-directory (example: --build-directory C:\lumberyard\dev\Win2019\bin\profile )")
build_class = internal_workspace.AbstractWorkspaceManager
if WINDOWS:
from ly_test_tools._internal.managers.platforms.windows import WindowsWorkspaceManager
build_class = WindowsWorkspaceManager
elif MAC:
from ly_test_tools._internal.managers.platforms.mac import MacWorkspaceManager
build_class = MacWorkspaceManager
instance = build_class(
build_directory=build_directory,
project=project,
tmp_path=tmp_path,
output_path=output_path,
)
return instance
def setup_bootstrap_project(workspace, project):
"""
Sets up the bootstrap.cfg file to be used for the given project
:param workspace: workspace to use
:param project: Lumberyard project to set as target
:return: None
"""
bootstrap_cfg = os.path.join(workspace.paths.dev(), "bootstrap.cfg")
os.chmod(bootstrap_cfg, stat.S_IWRITE)
lines = None
with open(bootstrap_cfg) as f:
lines = f.readlines()
found_gamefolder = False
for i, line in enumerate(lines):
if line.lstrip().startswith("sys_game_folder"):
lines[i] = f"sys_game_folder={project}\n"
found_gamefolder = True
break
assert found_gamefolder, "'sys_game_folder' not found in bootstrap.cfg"
with open(bootstrap_cfg, "w") as f:
f.writelines(lines)
def setup_builtin_workspace(workspace, test_name, artifact_folder_count):
# type: (internal_workspace.AbstractWorkspaceManager, str, int) -> internal_workspace.AbstractWorkspaceManager
"""
Reconfigures a workspace instance to its defaults.
Usually test authors should rely on the provided "workspace" fixture, but these helpers can be used to
achieve the same result.
:param workspace: workspace to use
:param test_name: the test name to be used by the artifact manager
:param artifact_folder_count: the number of folders to create for the test_name, each one will have an index
appended at the end to handle naming collisions.
:return: the configured workspace object, useful for method chaining
"""
workspace.setup()
workspace.artifact_manager.set_test_name(test_name=test_name, amount=artifact_folder_count)
return workspace
def teardown_builtin_workspace(workspace):
# type: (internal_workspace.AbstractWorkspaceManager) -> internal_workspace.AbstractWorkspaceManager
"""
Stop the asset processor and perform teardown on the specified workspace.
Usually test authors should rely on the provided "workspace" fixture, but these helpers can be used to
achieve the same result.
:param workspace: workspace to use
:return: the configured workspace object, useful for method chaining
"""
workspace.teardown()
return workspace
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,422 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
File system related functions.
"""
import errno
import glob
import logging
import os
import psutil
import shutil
import stat
import sys
import tarfile
import time
import zipfile
import ly_test_tools.environment.process_utils as process_utils
logger = logging.getLogger(__name__)
ONE_KIB = 1024
ONE_MIB = 1024 * ONE_KIB
ONE_GIB = 1024 * ONE_MIB
def check_free_space(dest, required_space, msg):
""" Make sure the required space is available on destination, raising an IOError if there is not. """
free_space = psutil.disk_usage(dest).free
if free_space < required_space:
raise IOError(
errno.ENOSPC,
f'{msg} {free_space / ONE_GIB:.2f} GiB '
f'vs {required_space / ONE_GIB:.2f} GiB')
def safe_makedirs(dest_path):
""" This allows an OSError in the case the directory cannot be created, which is logged but does not propagate."""
try:
logger.debug(f'Creating directory "{dest_path}"')
os.makedirs(dest_path)
except OSError as e:
if e.errno == errno.EEXIST:
pass
elif e.errno == errno.EACCES and sys.platform == 'win32' and dest_path.endswith(':\\'):
# In this case, windows will raise EACCES instead of EEXIST if you try to make a directory at the root.
pass
else:
logger.debug(f'Could not create directory: "{dest_path}".')
raise
def get_newest_file_in_dir(path, exts):
""" Find the newest file in a directory, matching the extensions provided. """
dir_iter = []
for ext in exts:
dir_iter.extend(glob.iglob(os.path.join(path, ext)))
try:
return max(dir_iter, key=os.path.getctime)
except ValueError:
# May not be any files in that directory.
return None
def remove_path_and_extension(src):
"""
Given a src, will strip off the path and the extension. Used in unzip and untgz
Example:
C:\\packages\\lumberyard-XXXX.zip would become lumberyard-XXX
"""
src_name = os.path.basename(src)
src_no_extension, _ = os.path.splitext(src_name)
return src_no_extension
def set_up_decompression(full_size, dest, src, force, allow_exists=False):
"""
Used in unzip and untgz, will check whether the dest has enough space and creates the new build path.
:param full_size: Size of zipped package
:param dest: Target unzip location
:param src: Location of the zipped package
:param force: Boolean determining whether to overwrite the build if it already exists
:param allow_exists: Boolean determining whether to log critical if the build already exists
:return: A tuple containing the unzipped build path and a bool determining whether the build already exists.
"""
exists = False
# Check free space leaving at least a GiB free.
check_free_space(dest, full_size + ONE_GIB, 'Not enough space to safely extract: ')
dst_path = os.path.join(dest, remove_path_and_extension(src))
# Cannot easily compare the zip contents to existing dir. Assumes builds of the same name are identical.
if os.path.exists(dst_path) and not force:
exists = True
# Only log critical if the user wants early termination of the command if the build exists
if allow_exists:
level = logging.getLevelName('INFO')
else:
level = logging.getLevelName('CRITICAL')
logger.log(level, f'Found existing {dst_path}. Will not overwrite.')
return dst_path, exists
return dst_path, exists
def unzip(dest, src, force=False, allow_exists=False):
"""
decompress src_path\\name.zip to the dest directory in a subdirectory called name.
Will strip assets names for lumberyard builds.
Example:
dest = D:\\builds
src = C:\\packages\\lumberyard-XXXX.zip
Result:
C:\\packages\\lumberyard-XXXX.zip decompressed to D:\\builds\\lumberyard-XXXX
src can be any file, but lumberyard asset builds will have their name shortened to match the build they belong to.
"""
with zipfile.ZipFile(src, 'r') as zip_file:
full_size = sum(info.file_size for info in zip_file.infolist())
dst_path, exists = set_up_decompression(full_size, dest, src, force, allow_exists)
if exists:
return dst_path
# Unzip and return final path.
start_time = time.time()
zip_file.extractall(dst_path)
secs = time.time() - start_time
if secs == 0:
secs = 0.01
logger.info(
f'Extracted {full_size / ONE_GIB:.2f} GiB '
f'from "{src}" to "{dst_path}" in '
f'{secs / 60:2.2f} minutes, '
f'at {(full_size / ONE_MIB) / secs:.2f} MiB/s.')
return dst_path
def untgz(dest, src, exact_tgz_size=False, force=False, allow_exists=False):
"""
decompress src_path\\name.tgz to the dest directory in a subdirectoy called name.
Will strip assets names for lumberyard builds.
Example:
dest = D:\\builds
src = C:\\packages\\lumberyard-XXXX.tgz
Result:
C:\\packages\\lumberyard-XXXX.tgz decompressed to D:\\builds\\lumberyard-XXXX
src can be any file, but lumberyard asset builds will have their name shortened to match the build they belong to.
"""
with tarfile.open(src) as tar_file:
# Determine exact size of tar if instructed, otherwise estimate.
if exact_tgz_size:
full_size = 0
for tarinfo in tar_file:
full_size += tarinfo.size
else:
full_size = os.stat(src).st_size * 4.5
dst_path, exists = set_up_decompression(full_size, dest, src, force, allow_exists)
if exists:
return dst_path
# Extract it and return final path.
start_time = time.time()
tar_file.extractall(dst_path)
secs = time.time() - start_time
if secs == 0:
secs = 0.01
logger.info(
f'Extracted {full_size / ONE_GIB:.2f} MiB '
f'from {src} to {dst_path} '
f'in {secs / 60:2.2f} minutes, '
f'at {(full_size / ONE_MIB) / secs:.2f} MiB/s.')
return dst_path
def change_permissions(path_list, perms):
""" Changes the permissions of the files and folders defined in the file list """
try:
for root, dirs, files in os.walk(path_list):
for dir_name in dirs:
os.chmod(os.path.join(root, dir_name), perms)
for file_name in files:
os.chmod(os.path.join(root, file_name), perms)
except OSError as e:
logger.warning(f"Couldn't change permission : Error: {e.filename} - {e.strerror}.")
return False
else:
return True
def unlock_file(file_name):
"""
Given a file name, unlocks the file for write access.
:param file_name: Path to a file
:return: True if unlock succeeded, else False
"""
if not os.access(file_name, os.W_OK):
os.chmod(file_name, stat.S_IWRITE)
logger.warning(f'Clearing write lock for file {file_name}.')
return True
else:
logger.info(f'File {file_name} not write locked. Unlocking file not necessary.')
return False
def lock_file(file_name):
"""
Given a file name, lock write access to the file.
:param file_name: Path to a file
:return: True if lock succeeded, else False
"""
if os.access(file_name, os.W_OK):
os.chmod(file_name, stat.S_IREAD)
logger.warning(f'Write locking file {file_name}')
return True
else:
logger.info(f'File {file_name} already locked. Locking file not necessary.')
return False
def remove_symlink(path):
try:
# Rmdir can delete a symlink without following the symlink to the original content
os.rmdir(path)
except OSError as e:
if e.errno != errno.ENOTEMPTY:
raise
def remove_symlinks(path, remove_root=False):
""" Removes all symlinks at the provided path and its subdirectories. """
for root, dirs, files in os.walk(path):
for name in dirs:
remove_symlink(os.path.join(root, name))
if remove_root:
remove_symlink(path)
def delete(file_list, del_files, del_dirs):
"""
Given a list of directory paths, delete will remove all subdirectories and files based on which flag is set,
del_files or del_dirs.
:param file_list: A string or an array of artifact paths to delete
:param del_files: True if delete should delete files
:param del_dirs: True if delete should delete directories
:return: True if delete was successful
"""
if isinstance(file_list, str):
file_list = [file_list]
for file_to_delete in file_list:
logger.info(f'Deleting "{file_to_delete}"')
try:
if del_dirs and os.path.isdir(file_to_delete):
change_permissions(file_to_delete, 0o777)
# Remove all symlinks before rmtree blows them away
remove_symlinks(file_to_delete)
shutil.rmtree(file_to_delete)
elif del_files and os.path.isfile(file_to_delete):
os.chmod(file_to_delete, 0o777)
os.remove(file_to_delete)
except OSError as e:
logger.warning(f'Could not delete {e.filename} : Error: {e.strerror}.')
return False
return True
def create_backup(source, backup_dir):
"""
Creates a backup of a single source file by creating a copy of it with the same name + '.bak' in backup_dir
e.g.: foo.txt is stored as backup_dir/foo.txt.bak
:param source: Full path to file to backup
:param backup_dir: Path to the directory to store backup.
"""
if not backup_dir or not os.path.isdir(backup_dir):
logger.error(f'Cannot create backup due to invalid backup directory {backup_dir}')
return
if not os.path.exists(source):
logger.warning(f'Source file {source} does not exist, aborting backup creation.')
return
source_filename = os.path.basename(source)
dest = os.path.join(backup_dir, f'{source_filename}.bak')
logger.info(f'Saving backup of {source} in {dest}')
if os.path.exists(dest):
logger.warning(f'Backup file already exists at {dest}, it will be overwritten.')
try:
shutil.copy(source, dest)
except Exception: # intentionally broad
logger.warning('Could not create backup, exception occurred while copying.', exc_info=True)
def restore_backup(original_file, backup_dir):
"""
Restores a backup file to its original location. Works with a single file only.
:param original_file: Full path to file to overwrite.
:param backup_dir: Path to the directory storing the backup.
"""
if not backup_dir or not os.path.isdir(backup_dir):
logger.error(f'Cannot restore backup due to invalid or nonexistent directory {backup_dir}.')
return
source_filename = os.path.basename(original_file)
backup = os.path.join(backup_dir, f'{source_filename}.bak')
if not os.path.exists(backup):
logger.warning(f'Backup file {backup} does not exist, aborting backup restoration.')
return
logger.info(f'Restoring backup of {original_file} from {backup}')
try:
shutil.copy(backup, original_file)
except Exception: # intentionally broad
logger.warning('Could not restore backup, exception occurred while copying.', exc_info=True)
def delete_oldest(path_glob, keep_num, del_files=True, del_dirs=False):
""" Delete oldest builds, keeping a specific number """
logger.info(
f'Deleting dirs: {del_dirs} files: {del_files} "{path_glob}", keeping {keep_num}')
paths = glob.iglob(path_glob)
paths = sorted(paths, key=lambda fi: os.path.getctime(fi), reverse=True)
return delete(paths[keep_num:], del_files, del_dirs)
def make_junction(dst, src):
"""Create a directory junction on Windows or a hardlink on macOS."""
if not os.path.isdir(src):
raise IOError(f"{src} is not a directory")
elif sys.platform == 'win32':
process_utils.check_output(["mklink", "/J", dst, src], shell=True)
elif sys.platform == 'darwin':
process_utils.check_output(["ln", dst, src])
else:
raise IOError(f"Unsupported operating system: {sys.platform}")
def split_path_where_exists(path):
"""
Splits a path into 2 parts: the part that exists and the part that doesn't.
:param path: the path to split
:return: a tuple (exists_part, remainder) where exists_part is the part that exists and remainder is the part that
doesn't. Either part may be None.
"""
current = path
remainder = None
while True:
if os.path.exists(current):
return current, remainder
next_, tail = os.path.split(current)
tail = tail or next_
remainder = tail if remainder is None else os.path.join(tail, remainder)
if next_ == current:
break
current = next_
return None, remainder
def sanitize_file_name(file_name):
"""
Replaces unsupported file name characters with a double underscore
:param file_name: The target file name to sanitize
:return: The sanitized name
"""
return ''.join(
'__' if c in ['\\', '/', ' ', ':', '*', '<', '>', '"', '|', '?'] + [chr(i) for i in range(32)] else c for c
in file_name)
def reduce_file_name_length(file_name, max_length):
"""
Reduces the length of the string file_name to match the length parameter.
:param file_name: string for the file name to reduce in length.
:param max_length: the length to reduce file_name to.
:return: file name string with a maximum length matching max_length.
"""
reduce_amount = len(file_name) - max_length
if len(file_name) > max_length:
file_name = file_name[:-reduce_amount]
return file_name
@@ -0,0 +1,469 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Process management functions, to supplement normal use of psutil and subprocess
"""
import logging
import os
import psutil
import subprocess
import ctypes
import ly_test_tools.environment.waiter as waiter
from ly_test_tools import WINDOWS, MAC
logger = logging.getLogger(__name__)
_PROCESS_OUTPUT_ENCODING = 'utf-8'
# Default list of processes names to kill
LY_PROCESS_KILL_LIST = [
'CrySCompileServer', 'Editor',
'Profiler', 'RemoteConsole',
'rc' # Resource Compiler
]
def kill_processes_named(names, ignore_extensions=False):
"""
Kills all processes with a given name
:param names: string process name, or list of strings of process name
:param ignore_extensions: ignore trailing file extension
"""
if not names:
return
names = [names] if isinstance(names, str) else names
if ignore_extensions:
names = [_remove_extension(name) for name in names]
# remove any blank names, which may empty the list
names = list(filter(lambda x: not x.isspace(), names))
if not names:
return
logger.info(f"Killing all processes named {names}")
process_list_to_kill = []
for process in _safe_get_processes(['name', 'pid']):
try:
proc_name = process.name()
except psutil.AccessDenied:
logger.info(f"Process {process} permissions error during kill_processes_named()", exc_info=True)
continue
except psutil.ProcessLookupError:
logger.debug(f"Process {process} could not be killed during kill_processes_named() and was likely already stopped", exc_info=True)
continue
except psutil.NoSuchProcess:
logger.debug(f"Process '{process}' was active when list of processes was requested but it was not found "
f"during kill_processes_named()", exc_info=True)
continue
if ignore_extensions:
proc_name = _remove_extension(proc_name)
if proc_name in names:
logger.debug(f"Found process with name {proc_name}. Attempting to kill...")
process_list_to_kill.append(process)
_safe_kill_process_list(process_list_to_kill)
def kill_processes_started_from(path):
"""
Kills all processes started from a given directory or executable
:param path: path to application or directory
"""
logger.info(f"Killing processes started from '{path}'")
if os.path.exists(path):
process_list = []
for process in _safe_get_processes():
try:
process_path = process.exe()
except (psutil.AccessDenied, psutil.NoSuchProcess):
continue
if process_path.lower().startswith(path.lower()):
process_list.append(process)
_safe_kill_process_list(process_list)
else:
logger.warning(f"Path:'{path}' not found")
def kill_processes_with_name_not_started_from(name, path):
"""
Kills all processes with a given name that NOT started from a directory or executable
:param name: name of application to look for
:param path: path where process shouldn't have started from
"""
path = os.path.join(os.getcwd(), os.path.normpath(path)).lower()
logger.info(f"Killing processes with name:'{name}' not started from '{path}'")
if os.path.exists(path):
proccesses_to_kill = []
for process in _safe_get_processes(["name", "pid"]):
try:
process_path = process.exe()
except (psutil.AccessDenied, psutil.NoSuchProcess) as ex:
continue
process_name = os.path.splitext(os.path.basename(process_path))[0]
if process_name == os.path.basename(name) and not os.path.dirname(process_path.lower()) == path:
logger.info("%s -> %s" % (os.path.dirname(process_path.lower()), path))
proccesses_to_kill.append(process)
_safe_kill_process_list(proccesses_to_kill)
else:
logger.warning(f"Path:'{path}' not found")
def kill_process_with_pid(pid, raise_on_missing=False):
"""
Kills the process with the specified pid
:param pid: the pid of the process to kill
:param raise_on_missing: if set to True, raise RuntimeError if the process does not already exist
"""
if pid is None:
logger.warning("Killing process id of 'None' will terminate the current python process!")
logger.info(f"Killing processes with id '{pid}'")
process = psutil.Process(pid)
if process.is_running():
_safe_kill_process(process)
elif raise_on_missing:
message = f"Process with id {pid} was not present"
logger.error(message)
raise RuntimeError(message)
def process_exists(name, ignore_extensions=False):
"""
Determines whether a process with the given name exists
:param name: process name
:param ignore_extensions: ignore trailing file extension
:return: A boolean determining whether the process is alive or not
"""
name = name.lower()
if ignore_extensions:
name = _remove_extension(name)
if name.isspace():
return False
for process in _safe_get_processes(["name"]):
try:
proc_name = process.name().lower()
except psutil.NoSuchProcess as e:
logger.debug(f"Process '{process}' was active when list of processes was requested but it was not found "
f"during process_exists()", exc_info=True)
continue
except psutil.AccessDenied as e:
logger.info(f"Permissions issue on {process} during process_exists check", exc_info=True)
continue
if ignore_extensions:
proc_name = _remove_extension(proc_name)
if proc_name == name:
return True
return False
def process_is_unresponsive(name):
"""
Check if the specified process is unresponsive.
Mac warning: this method assumes that a process is not responsive if it is sleeping or waiting, this is true for
'active' applications, but may not be the case for power optimized applications.
:param name: the name of the process to check
:return: True if the specified process is unresponsive and False otherwise
"""
if WINDOWS:
output = check_output(['tasklist',
'/FI', f'IMAGENAME eq {name}',
'/FI', 'STATUS eq NOT RESPONDING'])
output = output.split(os.linesep)
for line in output:
if line and name.startswith(line.split()[0]):
logger.debug(f"Process '{name}' was unresponsive.")
logger.debug(line)
return True
logger.debug(f"Process '{name}' was not unresponsive.")
return False
elif MAC:
cmd = ["ps", "-axc", "-o", "command,state"]
output = check_output(cmd)
for line in output.splitlines()[1:]:
info = [l.strip() for l in line.split(" ") if l.strip() != '']
state = info[-1]
pname = " ".join(info[0:-1])
if pname == name:
logger.debug(f"{pname}: {state}")
if "R" not in state:
logger.debug(f"Process {name} was unresponsive.")
return True
logger.debug(f"Process '{name}' was not unresponsive.")
return False
else:
raise NotImplementedError('Only Windows and Mac hosts are supported.')
def check_output(command, **kwargs):
"""
Forwards arguments to subprocess.check_output so better error messages can be displayed upon failure.
If you need the stderr output from a failed process then pass in stderr=subprocess.STDOUT as a kwarg.
:param command: A list of the command to execute and its arguments as split by whitespace.
:param kwargs: Keyword args forwarded to subprocess.check_output.
:return: Output from the command if it succeeds.
"""
cmd_string = command
if type(command) == list:
cmd_string = ' '.join(command)
logger.info(f'Executing "check_output({cmd_string})"')
try:
output = subprocess.check_output(command, **kwargs).decode(_PROCESS_OUTPUT_ENCODING)
except subprocess.CalledProcessError as e:
logger.error(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
raise
logger.info(f'Successfully executed "check_output({cmd_string})"')
return output
def safe_check_output(command, **kwargs):
"""
Forwards arguments to subprocess.check_output so better error messages can be displayed upon failure.
This function eats the subprocess.CalledProcessError exception upon command failure and returns the output.
If you need the stderr output from a failed process then pass in stderr=subprocess.STDOUT as a kwarg.
:param command: A list of the command to execute and its arguments as split by whitespace.
:param kwargs: Keyword args forwarded to subprocess.check_output.
:return: Output from the command regardless of its return value.
"""
cmd_string = command
if type(command) == list:
cmd_string = ' '.join(command)
logger.info(f'Executing "check_output({cmd_string})"')
try:
output = subprocess.check_output(command, **kwargs).decode(_PROCESS_OUTPUT_ENCODING)
except subprocess.CalledProcessError as e:
output = e.output
logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
else:
logger.info(f'Successfully executed "check_output({cmd_string})"')
return output
def check_call(command, **kwargs):
"""
Forwards arguments to subprocess.check_call so better error messages can be displayed upon failure.
:param command: A list of the command to execute and its arguments as if split by whitespace.
:param kwargs: Keyword args forwarded to subprocess.check_call.
:return: An exitcode of 0 if the call succeeds.
"""
cmd_string = command
if type(command) == list:
cmd_string = ' '.join(command)
logger.info(f'Executing "check_call({cmd_string})"')
try:
subprocess.check_call(command, **kwargs)
except subprocess.CalledProcessError as e:
logger.error(f'Command "{cmd_string}" failed with returncode {e.returncode}')
raise
logger.info(f'Successfully executed "check_call({cmd_string})"')
return 0
def safe_check_call(command, **kwargs):
"""
Forwards arguments to subprocess.check_call so better error messages can be displayed upon failure.
This function eats the subprocess.CalledProcessError exception upon command failure and returns the exit code.
:param command: A list of the command to execute and its arguments as if split by whitespace.
:param kwargs: Keyword args forwarded to subprocess.check_call.
:return: An exitcode of 0 if the call succeeds, otherwise the exitcode returned from the failed subprocess call.
"""
cmd_string = command
if type(command) == list:
cmd_string = ' '.join(command)
logger.info(f'Executing "check_call({cmd_string})"')
try:
subprocess.check_call(command, **kwargs)
except subprocess.CalledProcessError as e:
logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}')
return e.returncode
else:
logger.info(f'Successfully executed "check_call({cmd_string})"')
return 0
def _safe_get_processes(attrs=None):
"""
Returns the process iterator without raising an error if the process list changes
:return: The process iterator
"""
processes = None
max_attempts = 10
for _ in range(max_attempts):
try:
processes = psutil.process_iter(attrs)
break
except (psutil.Error, RuntimeError):
logger.debug("Unexpected error", exc_info=True)
continue
return processes
def _safe_kill_process(proc):
"""
Kills a given process without raising an error
:param proc: The process to kill
"""
try:
logger.info(f"Terminating process '{proc.name()}' with id '{proc.pid}'")
_terminate_and_confirm_dead(proc)
except psutil.AccessDenied:
logger.warning("Termination failed, Access Denied", exc_info=True)
except psutil.NoSuchProcess:
logger.debug("Termination request ignored, process was already terminated during iteration", exc_info=True)
except Exception: # purposefully broad
logger.warning("Unexpected exception while terminating process", exc_info=True)
def _safe_kill_process_list(proc_list):
"""
Kills a given process without raising an error
:param proc_list: The process list to kill
"""
def on_terminate(proc):
print(f"process '{proc.name()}' with id '{proc.pid}' terminated with exit code {proc.returncode}")
for proc in proc_list:
try:
logger.info(f"Terminating process '{proc.name()}' with id '{proc.pid}'")
proc.kill()
except psutil.AccessDenied:
logger.warning("Termination failed, Access Denied", exc_info=True)
except psutil.NoSuchProcess:
logger.debug("Termination request ignored, process was already terminated during iteration", exc_info=True)
except Exception: # purposefully broad
logger.warning("Unexpected exception while terminating process", exc_info=True)
psutil.wait_procs(proc_list, timeout=30, callback=on_terminate)
def _terminate_and_confirm_dead(proc):
"""
Kills a process and waits for the process to stop running.
:param proc: A process to kill, and wait for proper termination
"""
def killed():
return not proc.is_running()
proc.kill()
waiter.wait_for(killed, exc=RuntimeError("Process did not terminate after kill command"))
def _remove_extension(filename):
"""
Returns a file name without its extension
:param filename: The name of a file
:return: The name of the file without the extension
"""
return filename.rsplit(".", 1)[0]
def close_windows_process(pid, timeout=20, raise_on_missing=False):
# type: (int, int, bool) -> None
"""
Closes a window using the windows api and checks the return code. An error will be raised if the window hasn't
closed after the timeout duration.
Note: This is for Windows only and will fail on any other OS
:param pid: The process id of the process window to close
:param timeout: How long to wait for the window to close (seconds)
:return: None
:param pid: the pid of the process to kill
:param raise_on_missing: if set to True, raise RuntimeError if the process does not already exist
"""
if not WINDOWS:
raise NotImplementedError("close_windows_process() is only implemented on Windows.")
if pid is None:
raise TypeError("Cannot close window with pid of None")
if not psutil.Process(pid).is_running():
if raise_on_missing:
message = f"Process with id {pid} was unexpectedly not present"
logger.error(message)
raise RuntimeError(message)
else:
logger.warning(f"Process with id {pid} was not present but option raise_on_missing is disabled. Unless "
f"a matching process gets opened, calling close_windows_process will spin until its timeout")
# Gain access to windows api
user32 = ctypes.windll.user32
# Set up C data types and function params
WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.wintypes.BOOL,
ctypes.wintypes.HWND,
ctypes.wintypes.LPARAM)
user32.EnumWindows.argtypes = [
WNDENUMPROC,
ctypes.wintypes.LPARAM]
user32.GetWindowTextLengthW.argtypes = [
ctypes.wintypes.HWND]
# This is called for each process window
def _close_matched_process_window(hwnd, _):
# type: (ctypes.wintypes.HWND, int) -> bool
"""
EnumWindows() takes a function argument that will return True/False to keep iterating or not.
Checks the windows handle's pid against the given pid. If they match, then the window will be closed and
returns False.
:param hwnd: A windows handle to check against the pid
:param _: Unused buffer parameter
:return: False if process was found and closed, else True
"""
# Get the process id of the handle
lpdw_process_id = ctypes.c_ulong()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(lpdw_process_id))
process_id = lpdw_process_id.value
# Compare to the process id
if pid == process_id:
# Close the window
WM_CLOSE = 16 # System message for closing window: 0x10
user32.PostMessageA(hwnd, WM_CLOSE, 0, 0)
# Found window for process id, stop iterating
return False
# Process not found, keep looping
return True
# Call the function on all of the handles
close_process_func = WNDENUMPROC(_close_matched_process_window)
user32.EnumWindows(close_process_func, 0)
# Wait for asyncronous termination
waiter.wait_for(lambda: pid not in psutil.pids(), timeout=timeout,
exc=TimeoutError(f"Process {pid} never terminated"))
@@ -0,0 +1,127 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Reg cleaner: tools for working with the lumberyard windows registry keys
"""
import logging
import os
import winreg
import ly_test_tools.environment.process_utils as process_utils
CONST_LY_REG = r'SOFTWARE\Amazon\Lumberyard'
AUTOMATION_EXCEPTION_LIST = [
os.path.join(CONST_LY_REG, r"Identity"),
os.path.join(CONST_LY_REG, r"Settings\DXInstalled"),
os.path.join(CONST_LY_REG, r"Settings\EditorSettingsVersion"),
os.path.join(CONST_LY_REG, r"Settings\EnableSourceControl"),
os.path.join(CONST_LY_REG, r"Settings\RC_EnableSourceControl"),
]
logger = logging.getLogger(__name__)
def _delete_child_keys_and_values(path, exception_list=None):
"""
Deletes all of the keys and values under the target registry path
:param path: the target path
:param exception_list: list of child keys and values to skip
:return: True if all of the keys and values were deleted. False, if any of the keys and values were skipped
"""
if exception_list is None:
exception_list = []
is_empty = True
handle = winreg.OpenKey(winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_ALL_ACCESS)
# Delete all of the empty keys under the target path
# Before checking for emptiness, attempt to empty out each key by calling delete_all_keys_and_values recursively
keys_to_delete = []
try:
index = 0
while True:
key_name = winreg.EnumKey(handle, index)
key_path = os.path.join(path, key_name)
if key_path not in exception_list and _delete_child_keys_and_values(key_path, exception_list):
keys_to_delete.append(key_name)
else:
is_empty = False
index += 1
except WindowsError:
pass
for key_name in keys_to_delete:
winreg.DeleteKey(handle, key_name)
# Delete all of the values under the target path
values_to_delete = []
try:
index = 0
while True:
value_name, _, _ = winreg.EnumValue(handle, index)
value_path = os.path.join(path, value_name)
if value_path not in exception_list:
values_to_delete.append(value_name)
else:
is_empty = False
index += 1
except WindowsError:
pass
for value_name in values_to_delete:
winreg.DeleteValue(handle, value_name)
winreg.CloseKey(handle)
return is_empty
def _delete_key(path, exception_list=None):
"""
Deletes the key at the target registry path
:param path: the target path
:param exception_list: list of child keys and values to skip
"""
try:
if _delete_child_keys_and_values(path, exception_list):
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path)
except WindowsError:
logger.debug("Cannot delete key because it does not exist, ignoring.", exc_info=True)
def clean_ly_keys(exception_list=None):
"""
Convenience function to delete all Lumberyard registry keys
:param exception_list: list of child keys and values to skip
"""
_delete_key(CONST_LY_REG, exception_list)
def create_ly_keys():
"""
Helper function to create Lumberyard registry keys that are essential to automated testing
"""
target_key = fr'HKEY_CURRENT_USER\{CONST_LY_REG}\Settings'
# DXInstalled is a flag set to ensure all machines have DirectX installed
process_utils.check_call(
["reg", "add", target_key, "/v", "DXInstalled", "/t", "REG_DWORD", "/d", "1", "/f"])
# The perforce plugin is enabled by default which is strongly recommended to be turned off for automation
process_utils.check_call(
["reg", "add", target_key, "/v", "EnableSourceControl", "/t", "REG_DWORD", "/d", "0", "/f"])
process_utils.check_call(
["reg", "add", target_key, "/v", "RC_EnableSourceControl", "/t", "REG_DWORD", "/d", "0", "/f"])
# The editor will ignore settings unless EditorSettingsVersion is what it expects
# The value it expects is in Code\Sandbox\Editor\Settings.cpp
process_utils.check_call(
["reg", "add", target_key, "/v", "EditorSettingsVersion", "/t", "REG_DWORD", "/d", "2", "/f"])
@@ -0,0 +1,53 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Functions that repeatedly run until a condition is met
"""
import time
def wait_for(fn_act, timeout=30, exc=None, interval=1):
"""
Continues to execute a given function until the function returns True. Raises an exception if the function does
not return True before the timeout
:param fn_act: The target function to execute
:param timeout: The set amount of time before raising an exception
:param exc: The exception to raise. An assertion error is raised by default
:param interval: The time to wait between subsequent function calls
"""
timeout_end = time.time() + timeout
while not fn_act():
if time.time() > timeout_end:
if exc is not None:
raise exc
else:
assert False, 'Timeout waiting for {}() after {}s.'.format(fn_act.__name__, timeout)
time.sleep(interval)
def wait_while(fn_act, timeout, exc=None, interval=1):
"""
Continues to execute a given function until the specified amount of time has passed. Raises an exception if the
function does not return True during this time.
:param fn_act: The target function to execute
:param timeout: The set amount of time to wait
:param exc: The exception to raise. An assertion error is raised by default
:param interval: The time to wait between subsequent function calls
"""
timeout_end = time.time() + timeout
while time.time() < timeout_end:
if not fn_act():
if exc is not None:
raise exc
else:
assert False, '{}() failed while waiting for {}s'.format(fn_act.__name__, timeout)
time.sleep(interval)
@@ -0,0 +1,248 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
The Watchdog parent class that spawns a separate thread to wait for a specific condition. If that condition is
fulfilled, then it can raise an exception or log an error.
"""
import threading
import logging
import os
import re
import psutil
import time
import ly_test_tools.environment.process_utils as process_utils
from ly_test_tools import WINDOWS
logger = logging.getLogger(__name__)
class WatchdogError(Exception):
""" Indicates that a Watchdog met its exception condition """
class Watchdog(object):
DEFAULT_JOIN_TIMEOUT = 10 # seconds
def __init__(self, bool_fn, interval=1, raise_on_condition=True, name='ly_test_watchdog', error_message=''):
# type: (func, int, bool, str, str) -> Watchdog
"""
A Watchdog object that takes in a boolean function. It spawns a thread that loops over the boolean function
until it returns True. If the boolean function returns True, then a flag will be set and an exception
will be raised when stop() is called.
:param bool_fn: A function that must return a boolean. It will be ran by the spawned thread until it's True
:param interval: The interval (in seconds) for how frequently the bool_fn is called on the thread.
:param raise_on_condition: If True, raises an exception when bool_fn returns True. If False, logs an error message
when bool_fn returns True.
:param name: The name of the thread.
:param error_message: The error message to log when bool_fn returns True. Defaults to printing the watchdog name
and function name.
"""
self.caught_failure = False
self.name = name
self._bool_fn = bool_fn
self._interval = interval
self._raise_on_condition = raise_on_condition
default_error_message = f'Watchdog: {name} caught an unexpected condition from function: {bool_fn.__name__}()'
self._error_message = error_message if error_message else default_error_message
self._shutdown = threading.Event()
self._watchdog_thread = threading.Thread(target=self._watchdog, name=name)
def start(self):
# type: () -> None
"""
Starts the watchdog's thread which spawns it and begins executing its target function. Also clears the thread's
shutdown Event. Clears the caught_failure attribute if the thread has been restarted.
:return: None
"""
self._shutdown.clear()
self._watchdog_thread.start()
self.caught_failure = False
def stop(self, join_timeout=DEFAULT_JOIN_TIMEOUT):
# type: () -> None
"""
Stops the watchdog's thread if it's executing by enabling its shutdown Event. If the target function's condition
was found, then it will either raise an exception or log an error message.
:param join_timeout: The timeout to wait for the watchdog thread to join.
:return: None
"""
# Set the Event attribute so that the thread stops running
self._shutdown.set()
# Join the watchdog thread back to primary thread
self._watchdog_thread.join(timeout=join_timeout)
if self.is_alive():
# thread has timed out because it's still alive
logger.error(f'Thread: {self.name} timed out when calling join()')
# No further action taken if nothing was caught
if not self.caught_failure:
return
# either raise an exception or log an error
if self._raise_on_condition:
raise WatchdogError(self._error_message)
logger.error(self._error_message)
def is_alive(self):
# type: () -> bool
"""
The thread is killed when it times out or its target returns True. Timed out threads are considered not alive.
:return: Returns True if the thread is alive, else False
"""
return self._watchdog_thread.is_alive()
def _watchdog(self):
# type: () -> None
"""
The main function of the watchdog thread. It will repeatedly call its target function until the target function
returns True, in which it will set the self.caught_failure attribute to True.
:return: None
"""
while True:
# check to see if the thread is shut down
if self._shutdown.wait(timeout=self._interval):
logger.info(f"Shutting down watchdog: {self.name}")
return
# call the target function and see if it returned True
if self._bool_fn():
self.caught_failure = True
return
class ProcessUnresponsiveWatchdog(Watchdog):
def __init__(self, process_id, interval=1, raise_on_condition=True, name='process_watchdog', error_message='',
unresponsive_timeout_seconds=30):
# type: (int, int, bool, str, str, int) -> ProcessUnresponsiveWatchdog
"""
Watches a process ID and reports if it is unresponsive for a given timeout. If multiple processes need to be
watched, then multiple watchdogs should be instantiated.
Note: This is for windows OS only.
:param process_id: The process id to watch
:param interval: The interval (in seconds) for how frequently the bool_fn is called on the thread.
:param raise_on_condition: If True, raises an exception when bool_fn returns True. If False, logs an error
message when bool_fn returns True.
:param name: The name of the thread.
:param error_message: The error message when bool_fn returns True. Defaults to the watchdog name and pid
:param unresponsive_timeout_seconds: How long the process needs to be unresponsive for in order for the watchdog
to report (in seconds).
"""
if not WINDOWS:
raise (NotImplementedError, "Process watchdog is only implemented on Windows.")
self._unresponsive_timeout = unresponsive_timeout_seconds
self._calculated_timeout_point = None
self._pid = process_id
self._process_name = psutil.Process(self._pid).name()
self._default_error_message = f"Process watchdog has found that process: {self._process_name} with pid: " \
f"{self._pid} was unresponsive during the test run. Investigate process " \
f"{self._process_name} for failures."
if not error_message:
error_message = self._default_error_message
super(ProcessUnresponsiveWatchdog, self).__init__(bool_fn=self._process_not_responding, interval=interval,
raise_on_condition=raise_on_condition, name=name,
error_message=error_message)
def _process_not_responding(self):
# type: () -> bool
"""
Checks to see if the process has been unresponsive longer than self._unresponsive_timeout. Once the process is
found to be unresponsive, it will keep track of how long it has been unresponsive for.
The cmd returns a string of not responding tasks in the following format:
"b'\r\n
Image Name PID Session Name Session# Mem Usage\r\n
========================= ======== ================ =========== ============\r\n
foo.exe 00000 Console 1 0 K\r\n'"
:return: True if the process has been unresponsive for self._unresponsive_timeout seconds, else False
"""
_PROCESS_OUTPUT_ENCODING = 'utf-8'
cmd = 'tasklist /FI "PID eq %d" /FI "STATUS eq not responding"' % self._pid
# searches for a process name and pid with white space in between them and at the end
regex = f'{self._process_name}\s+{self._pid}\s'
status = process_utils.check_output(cmd)
if re.search(regex, status):
if self._calculated_timeout_point is None:
# First instance of process unresponsive
self._calculated_timeout_point = time.time() + self._unresponsive_timeout
elif time.time() >= self._calculated_timeout_point:
return True
else:
# Process is responsive
self._calculated_timeout_point = None
return False
def get_pid(self):
# type: () -> int
"""
Get the pid that the watchdog is watching.
:return: The process id of the watchdog
"""
return self._pid
class CrashLogWatchdog(Watchdog):
def __init__(self, log_path, interval=1, raise_on_condition=True, name='crash_log_watchdog', error_message=''):
# type: (str, int, bool, str, str) -> CrashLogWatchdog
"""
A watchdog that watches if a file gets created and reports if it finds the file. The watchdog will check to see
if the file already exists and removes it before starting to watch.
:param log_path: The absolute path of the log to watch
:param interval: The interval (in seconds) for how frequently the bool_fn is called on the thread.
:param raise_on_condition: If True, raises an exception when bool_fn returns True. If False, logs an error message
when bool_fn returns True.
:param name: The name of the thread.
:param error_message: The error message to log when bool_fn returns True. Defaults to printing the watchdog name
"""
self._log_path = log_path
def crash_exists():
return os.path.exists(log_path)
if not error_message:
error_message = f"Crash log watchdog has detected an error log found at {log_path} during the test run. \
Investigate the process that creates the error log for failures."
if os.path.exists(log_path):
logger.info(f"Removing existing {log_path} when initializing crash log watchdog.")
os.remove(log_path)
super(CrashLogWatchdog, self).__init__(bool_fn=crash_exists, interval=interval,
raise_on_condition=raise_on_condition,
name=name, error_message=error_message)
def stop(self):
# type: () -> None
"""
Prints the crash log if able when stopping the watchdog.
:return: None
"""
header = "================= Crash Log Print =================\n"
if self.caught_failure:
print(header)
with open(self._log_path, "r") as crash_log:
for line in crash_log:
print(line.strip('\n'))
print("=" * len(header) + "\n")
super(CrashLogWatchdog, self).stop()
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,42 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Utility package for LyTestTools.
Provides image capturing functionality.
"""
import pyscreenshot
def screencap(x1=None, y1=None, x2=None, y2=None, filename='screenshot.png'):
"""
Capture an arbitrary portion (in absolute coordinates) of the screen to a file. Note currently accepts coords
for the leftmost screen only. Any span beyond/below will be blank.
If any of the coordinate parameters is None, the entire screen will be captured.
Note this is a brittle way to verify a portion of an application.
:param x1: Top left X
:param y1: Top left Y
:param x2: Bottom right X
:param y2: Bottom right Y
:param filename: Filename to save to.
:return: Image that was saved in PIL "Image" class.
"""
if x1 is not None and x2 is not None and y1 is not None and y2 is not None:
im = pyscreenshot.grab(bbox=(x1, y1, x2, y2), childprocess=False)
else:
im = pyscreenshot.grab(childprocess=False)
im.save(filename)
return im
@@ -0,0 +1,161 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Python implementation of Quaternion Structural Similarity by Amir Kolaman and Orly Yadid-Pecht as seen in
IEEE Transaction on Image Processing Vol 21 No 4 April 2012.
"""
import imageio
import numpy
import os
from scipy import ndimage
def _quaternion_matrix_conj(q):
q_out = numpy.zeros(q.shape)
q_out[:, :, 0] = q[:, :, 0]
q_out[:, :, 1] = -q[:, :, 1]
q_out[:, :, 2] = -q[:, :, 2]
q_out[:, :, 3] = -q[:, :, 3]
return q_out
def _quaternion_matrix_dot(q1, q2):
return numpy.sqrt(numpy.sum(numpy.multiply(q1, q2), 2))
def _quaternion_matrix_norm(q):
return _quaternion_matrix_dot(q, q)
def _quaternion_matrix_mult(q1, q2):
"""q = (q1[0] * q2[0] - q1[1] * q2[1] - q1[2] * q2[2] - q1[3] * q2[3],
q1[0] * q2[1] + q1[1] * q2[0] - q1[2] * q2[3] + q1[3] * q2[2],
q1[0] * q2[2] + q1[1] * q2[3] + q1[2] * q2[0] - q1[3] * q2[1],
q1[0] * q2[3] - q1[1] * q2[2] + q1[2] * q2[1] + q1[3] * q2[0])"""
# add error checking for q1 and q2 being same size
q = numpy.zeros(q1.shape)
q[:, :, 0] = numpy.multiply(q1[:, :, 0], q2[:, :, 0]) - numpy.multiply(q1[:, :, 1], q2[:, :, 1]) \
- numpy.multiply(q1[:, :, 2], q2[:, :, 2]) - numpy.multiply(q1[:, :, 3], q2[:, :, 3])
q[:, :, 1] = numpy.multiply(q1[:, :, 0], q2[:, :, 1]) + numpy.multiply(q1[:, :, 1], q2[:, :, 0]) \
- numpy.multiply(q1[:, :, 2], q2[:, :, 3]) + numpy.multiply(q1[:, :, 3], q2[:, :, 2])
q[:, :, 2] = numpy.multiply(q1[:, :, 0], q2[:, :, 2]) + numpy.multiply(q1[:, :, 1], q2[:, :, 3]) \
+ numpy.multiply(q1[:, :, 2], q2[:, :, 0]) - numpy.multiply(q1[:, :, 3], q2[:, :, 1])
q[:, :, 3] = numpy.multiply(q1[:, :, 0], q2[:, :, 3]) - numpy.multiply(q1[:, :, 1], q2[:, :, 2]) \
+ numpy.multiply(q1[:, :, 2], q2[:, :, 1]) + numpy.multiply(q1[:, :, 3], q2[:, :, 0])
return q
def _quaternion_matrix_div(q1, q2):
q2_norm = _quaternion_matrix_norm(q2)
q = _quaternion_matrix_mult(q1, _quaternion_matrix_conj(q2))
return numpy.divide(q, numpy.dstack([q2_norm] * 4))
def qssim(screenshot, goldenimage, channel_max=255, diff_path='.'):
"""
Returns the mean quaternion similarity index between two images.
For images that are the same the expected result is 1.000.
By default we are assuming a channel max of 255(8bit channels)
There are a series of tuning parameters that are taken from the 2004 paper by Wang et al
Image Quality Assesment: From Error Visibility to Structural Similarity.
:param screenshot: Screenshot filename to test
:param goldenimage: Golden image to test against.
:param channel_max: Maximum channel value.
:param diff_path: Target path where diff image should be stored.
:return: Mean quaternion similarity from 0.00->1.00 (identical).
"""
# load image and copy it into a 4 channel array to treat rgb like quaternions
img1 = imageio.imread(screenshot)
img2 = imageio.imread(goldenimage)
# Avoid later precision issues
img1 = img1.astype(numpy.float64) / channel_max
img2 = img2.astype(numpy.float64) / channel_max
im1_size = img1.shape
im2_size = img2.shape
# check im1 and im2 are same size
hue1 = numpy.zeros((im1_size[0], im1_size[1], im1_size[2] + 1))
hue2 = numpy.zeros((im1_size[0], im1_size[1], im1_size[2] + 1))
mu1 = numpy.zeros((im1_size[0], im1_size[1], im1_size[2] + 1))
mu2 = numpy.zeros((im1_size[0], im1_size[1], im1_size[2] + 1))
offset1 = numpy.zeros((im1_size[0], im1_size[1], im1_size[2] + 1))
offset2 = numpy.zeros((im1_size[0], im1_size[1], im1_size[2] + 1))
hue1[:, :, 1:4] = img1
hue2[:, :, 1:4] = img2
mu1[:, :, 1:4] = img1
mu2[:, :, 1:4] = img2
# Algorithm tuning parameters. Can me modified as needed.
sigma = 1.5
# These parameters are just to prevent divide by zero issues.
L = 1
K1 = 0.01
K2 = 0.03
C1 = (K1 * L) ** 2
C2 = (K2 * L) ** 2
offset1[:, :, 0] = C1
offset2[:, :, 0] = C2
# blur each color channel of both images
mu1 = ndimage.filters.gaussian_filter1d(mu1, sigma, 0)
mu1 = ndimage.filters.gaussian_filter1d(mu1, sigma, 1)
mu2 = ndimage.filters.gaussian_filter1d(mu2, sigma, 0)
mu2 = ndimage.filters.gaussian_filter1d(mu2, sigma, 1)
mu1_sq = _quaternion_matrix_mult(mu1, _quaternion_matrix_conj(mu1))
mu2_sq = _quaternion_matrix_mult(mu2, _quaternion_matrix_conj(mu2))
mu12 = _quaternion_matrix_mult(mu1, _quaternion_matrix_conj(mu2))
hue1_sq = _quaternion_matrix_mult(hue1 - mu1, _quaternion_matrix_conj(hue1 - mu1))
hue2_sq = _quaternion_matrix_mult(hue2 - mu2, _quaternion_matrix_conj(hue2 - mu2))
hue12 = _quaternion_matrix_mult(hue1 - mu1, _quaternion_matrix_conj(hue2 - mu2))
sigma1 = ndimage.filters.gaussian_filter1d(hue1_sq, sigma, 0)
sigma1 = ndimage.filters.gaussian_filter1d(sigma1, sigma, 1)
sigma2 = ndimage.filters.gaussian_filter1d(hue2_sq, sigma, 0)
sigma2 = ndimage.filters.gaussian_filter1d(sigma2, sigma, 1)
sigma12 = ndimage.filters.gaussian_filter1d(hue12, sigma, 0)
sigma12 = ndimage.filters.gaussian_filter1d(sigma12, sigma, 1)
numerator1 = 2 * mu12 + offset1
numerator2 = 2 * sigma12 + offset2
denominator1 = mu1_sq + mu2_sq + offset1
denominator2 = sigma1 + sigma2 + offset2
part1 = _quaternion_matrix_norm(numerator1) / denominator1[:, :, 0]
part2 = _quaternion_matrix_norm(numerator2) / denominator2[:, :, 0]
qssim_map = numpy.multiply(part1, part2)
extension = os.path.splitext(screenshot)[1]
screenshot_name = os.path.basename(screenshot)
diff_name = '.'.join(screenshot_name.split('.')[:-1]) + "_diff" + extension
diff_full_path = os.path.join(diff_path, diff_name)
imageio.imwrite(diff_full_path, (qssim_map * channel_max).astype(numpy.uint8))
return ndimage.mean(numpy.abs(qssim_map))
if __name__ == "__main__":
""" If this is run by accident, inform user that this is a module, not a separate script. """
print('qssim.py is not a standalone script.')
@@ -0,0 +1,15 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
from ly_test_tools.launchers.platforms.base import Launcher
from ly_test_tools.launchers.platforms.mac.launcher import MacLauncher
from ly_test_tools.launchers.platforms.win.launcher import WinLauncher, DedicatedWinLauncher, WinEditor
from ly_test_tools.launchers.platforms.android.launcher import AndroidLauncher
@@ -0,0 +1,32 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Exceptions that can occur while interacting with a Launcher
"""
class CrashError(Exception):
""" Indicates an unexpected termination """
class SetupError(Exception):
""" Indicates error during setup """
class TeardownError(Exception):
""" Indicates error during teardown """
class WaitTimeoutError(Exception):
""" Indicates a timeout was reaching while waitinf for a process """
class ProcessNotStartedError(Exception):
""" Indicates that the process was never started and was required for the operation """
@@ -0,0 +1,63 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Main launchers module, provides a facade for creating launchers.
"""
import logging
import ly_test_tools._internal.managers.workspace
import ly_test_tools
log = logging.getLogger(__name__)
def create_launcher(workspace, launcher_platform=ly_test_tools.HOST_OS_PLATFORM, args=None):
# type: (ly_test_tools.managers.workspace.WorkspaceManager, str, List[str]) -> Launcher
"""
Create a launcher compatible with the specified workspace, if no specific launcher is found return a generic one.
:param workspace: lumberyard workspace to use
:param launcher_platform: the platform to target for a launcher (i.e. 'windows' or 'android')
:param args: List of arguments to pass to the launcher's 'args' argument during construction
:return: Launcher instance
"""
launcher_class = ly_test_tools.LAUNCHERS.get(launcher_platform, ly_test_tools.HOST_OS_PLATFORM)
return launcher_class(workspace, args)
def create_dedicated_launcher(workspace, launcher_platform=ly_test_tools.HOST_OS_DEDICATED_SERVER, args=None):
# type: (ly_test_tools.managers.workspace.WorkspaceManager, str, List[str]) -> Launcher
"""
Create a dedicated launcher compatible with the specified workspace. Dedicated Launcher is only supported on the
Linux and Windows Platform
:param workspace: lumberyard workspace to use
:param launcher_platform: the platform to target for a launcher (i.e. 'windows_dedicated' for DedicatedWinLauncher)
:param args: List of arguments to pass to the launcher's 'args' argument during construction
:return: Launcher instance
"""
launcher_class = ly_test_tools.LAUNCHERS.get(launcher_platform, ly_test_tools.HOST_OS_DEDICATED_SERVER)
return launcher_class(workspace, args)
def create_editor(workspace, launcher_platform=ly_test_tools.HOST_OS_EDITOR, args=None):
# type: (ly_test_tools.managers.workspace.WorkspaceManager, str, List[str]) -> Launcher
"""
Create an Editor compatible with the specified workspace.
Editor is only officially supported on the Windows Platform.
:param workspace: lumberyard workspace to use
:param launcher_platform: the platform to target for a launcher (i.e. 'windows_dedicated' for DedicatedWinLauncher)
:param args: List of arguments to pass to the launcher's 'args' argument during construction
:return: Editor instance
"""
launcher_class = ly_test_tools.LAUNCHERS.get(launcher_platform, ly_test_tools.HOST_OS_EDITOR)
return launcher_class(workspace, args)
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,283 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Wrapper to manage launching Lumberyard-created apps on Android.
Assumptions for running automation:
-The Android SDK has been installed, and adb is available on the path
-Each attached phone is accessible and:
1. trusts the connected computer (Run "adb devices" in the command line to trust the computer)
2. is unlocked
3. has its USB settings set to File Transfer
"""
import json
import logging
import os
from subprocess import CalledProcessError
import six
import ly_test_tools.mobile.android
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.launchers.exceptions
import ly_test_tools.environment.waiter
from ly_test_tools.launchers.platforms.base import Launcher
from ly_test_tools import HOST_OS_PLATFORM
log = logging.getLogger(__name__)
def get_package_name(project_path):
"""
Gets the Package name from the project's settings JSON.
:param project_path: The project path of the project
:return: The Package name from the settings JSON
"""
project_json_path = os.path.join(project_path, 'project.json')
with open(project_json_path) as json_file:
json_list = json.loads(json_file.read())
try:
package = json_list['android_settings']['package_name']
except KeyError as err:
problem = ly_test_tools.launchers.exceptions.SetupError(
'Package name not found in {}'.format(project_json_path))
six.raise_from(problem, err)
else:
return package
def get_pid(package_name, adb_prefix):
"""
Gets the PID for a process running the specified package.
:param package_name: The package name of the game
:param adb_prefix: list representing ADB command prefix which is
generally either ['adb'] or ['adb -s [device ID]']
:return: The package's process ID if it exists, else None
"""
# Check android version number; this will fail if a device ID is not set and multiple devices are connected
version_cmd = []
version_cmd.extend(adb_prefix)
version_cmd.extend(['shell', 'getprop', 'ro.build.version.sdk'])
version = process_utils.check_output(version_cmd)
# Use the "ps piped to grep" command for API 23 and under, else use the pidof command
pid_cmd = []
pid_cmd.extend(adb_prefix)
if int(version.strip()) >= 24:
pid_cmd.extend(['shell', 'pidof', package_name])
else:
pid_cmd.extend(['shell', 'ps', '|', 'grep', '{}'.format(package_name)])
try:
pid = process_utils.check_output(pid_cmd)
except Exception: # purposefully broad
log.exception(f"Exception trying to find a Process ID running package {package_name}, "
f"when executing '{pid_cmd}'\n"
f"When this occurs, it is possible the application crashed when launched. "
"We recommend launching the application manually to verify it is not crashing.",
exc_info=True)
pid = None
# pidof will only give us an ID, but ps gives multiple items -- we only want the second one in the latter case
if pid and len(pid.split()) > 1:
pid = pid.split()[1]
return pid
def generate_android_map_command(args_list):
"""
Takes a list of executable args and returns a the android map command to use with the autoexec.cfg file
:param args_list: list representing args to execute with the current game executable.
:return: map command to use with android in the autoexec.cfg file
i.e.: 'map simple_jacklocomotion'
"""
map_cmd = ''
for arg in args_list:
if arg == '+map':
map_cmd = "{}{}".format('map ', args_list[args_list.index(arg) + 1])
return map_cmd
class AndroidLauncher(Launcher):
def __init__(self, workspace, args):
super(AndroidLauncher, self).__init__(workspace, args)
self._adb_prefix_command = ['adb']
self._device_id = None
self.launch_proc = None
self.package_name = get_package_name(os.path.join(self.workspace.paths.dev(),
self.workspace.project))
self._device_id = self.get_device_config(config_file=self.workspace.paths.devices_file(),
device_section='android',
device_key='id')
log.info('Setting Android device ID: {}'.format(self._device_id))
self._adb_prefix_command.extend(['-s', self._device_id])
log.info("Initialized Android Launcher for device ID: {}".format(self._device_id))
def _enable_android_capabilities(self):
"""
Enables the required settings for Android device TCP tunnel reversing/forwarding to the host machine.
:return: None
"""
# Undo any existing port changes first.
ly_test_tools.mobile.android.undo_tcp_port_changes(self._device_id)
# Handle tunneling for Android connections:
ly_test_tools.mobile.android.reverse_tcp(self._device_id, '61453', '61453') # Shader Compiler
ly_test_tools.mobile.android.reverse_tcp(self._device_id, '45643', '45643') # Asset Processor
ly_test_tools.mobile.android.forward_tcp(self._device_id, '4600', '4600') # Remote Console
def _is_valid_android_environment(self):
"""
Verifies the current OS can run Android Debug Bridge (ADB) on a connected Android device.
:return: True if the current environment is valid for Android,
otherwise raises NotImplementedError or SetupError with the issue encountered.
"""
if not ly_test_tools.mobile.android.can_run_android():
raise NotImplementedError(
f'Android setup not detected on HOST_OS_PLATFORM: "{HOST_OS_PLATFORM}"\n'
'Setup Android Debug Bridge (ADB) with connected Android device to run Android tests.')
connected_devices = ly_test_tools.mobile.android.get_devices()
if not connected_devices:
raise ly_test_tools.launchers.exceptions.SetupError(
'No connected devices found when using the "adb devices" command - '
f'got connected_devices: "{connected_devices}".\n'
'Please connect an Android device to the host machine and add its ID to the ly_test_tools config file, '
f'located at: "{self.workspace.paths.devices_file()}"')
return True
def setup(self):
# Backup
self.backup_settings()
# Enable Android capabilities and verify environment is setup before continuing.
self._is_valid_android_environment()
self._enable_android_capabilities()
# Modify and re-configure
self.configure_settings()
self.workspace.shader_compiler.start()
super(AndroidLauncher, self).setup()
def teardown(self):
ly_test_tools.mobile.android.undo_tcp_port_changes(self._device_id)
self.restore_settings()
self.workspace.shader_compiler.stop()
super(AndroidLauncher, self).teardown()
def configure_settings(self):
"""
Configures system level settings and syncs the launcher to the targeted device ID.
:return: None
"""
self.workspace.settings.modify_bootstrap_setting('sys_game_folder', self.workspace.project)
self.workspace.settings.modify_bootstrap_setting('connect_to_remote', 1)
self.workspace.settings.modify_bootstrap_setting('android_connect_to_remote', 1)
self.workspace.settings.modify_bootstrap_setting('wait_for_connect', 1)
self.workspace.settings.modify_bootstrap_setting('remote_ip', '127.0.0.1')
self.workspace.settings.modify_bootstrap_setting('remote_port', '45643')
self.workspace.settings.modify_platform_setting('r_AssetProcessorShaderCompiler', 1)
self.workspace.settings.modify_platform_setting('r_ShadersAsyncCompiling', 0)
self.workspace.settings.modify_platform_setting('r_ShadersRemoteCompiler', 1)
self.workspace.settings.modify_platform_setting('r_ShadersAllowCompilation', 1)
self.workspace.settings.modify_platform_setting('r_ShadersAsyncActivation', 0)
self.workspace.settings.modify_platform_setting('r_ShaderCompilerServer', '127.0.0.1')
self.workspace.settings.modify_platform_setting('r_ShaderCompilerPort', '61453')
self.workspace.settings.modify_platform_setting("log_RemoteConsoleAllowedAddresses", '127.0.0.1')
def launch(self):
"""
Launches the APK matching self.package_name to the device that matches self._device_id.
This method will overwrite any existing autoexec config file on the device if this launcher's args are set,
which will allow it to do things such as load a level with the "map" command.
:return: None
"""
# Handles loading the level with the "map" command.
autoexec_cfg = self.workspace.paths.autoexec_file()
file_destination = (
f'/sdcard/Android/data/{self.package_name}/files/{self.workspace.project}/autoexec.cfg')
if os.path.isfile(autoexec_cfg):
ly_test_tools.mobile.android.push_files_to_device(
source=autoexec_cfg,
destination=file_destination,
device=self._device_id
)
launch_cmd = []
launch_cmd.extend(self._adb_prefix_command)
launch_cmd.extend(['shell',
'monkey',
'-p',
self.package_name,
'-c',
'android.intent.category.LAUNCHER',
'1'])
try:
launch_result = process_utils.check_output(launch_cmd)
except CalledProcessError as error:
# Format the error to be more human readable:
error_output = error.output.decode('utf-8').strip().replace('bash arg: ', '')
raise ly_test_tools.launchers.exceptions.SetupError(
f'\nGot error output: {error_output.splitlines()}\n'
f'ADB command used: {error.cmd}\n'
f'Android APK not located - verify the "{self.package_name}" package is installed.'
)
if 'Monkey Aborted' in launch_result:
log.error(f'Android APK launch failed! Command executed was "{launch_cmd}" with output: {launch_result}')
raise ly_test_tools.launchers.exceptions.SetupError(
f'Android APK launch failed immediately for command "{launch_cmd}"')
else:
log.debug(f"Started Android Launcher with command: {launch_cmd}")
def is_alive(self):
"""
Checks that the package matching self.package_name is running on the device matching self._device_id
:return: whether a process for the stored package name is currently running on a connected device
"""
if get_pid(self.package_name, self._adb_prefix_command):
return True
return False
def kill(self):
"""
Attempts to force quit any running processes with the stored package name on the device
that is set to self._device_id via the self._adb_prefix_command
:return: None
"""
# Using certain ADB commands will throw if multiple devices are connected and device_id is not yet set
forcestop_cmd = []
forcestop_cmd.extend(self._adb_prefix_command)
forcestop_cmd.extend(['shell',
'am',
'force-stop',
self.package_name])
process_utils.check_call(forcestop_cmd)
log.debug("Android Launcher terminated successfully")
def binary_path(self):
raise NotImplementedError("Android does not have a binary path")
@@ -0,0 +1,357 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Basic interface to interact with lumberyard launcher
"""
import logging
import os
from configparser import ConfigParser
import six
import ly_test_tools.launchers.exceptions
import ly_test_tools.environment.process_utils
import ly_test_tools.environment.waiter
log = logging.getLogger(__name__)
class Launcher(object):
def __init__(self, workspace, args):
# type: (ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager, List[str]) -> None
"""
Constructor for a generic launcher, requires a reference to the containing workspace and a list of arguments
to pass to the game during launch.
:param workspace: Workspace containing the launcher
:param args: list of arguments passed to the game during launch
"""
log.debug(f"Initializing launcher for workspace '{workspace}' with args '{args}'")
self.workspace = workspace # type: ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager
if args:
if isinstance(args, list):
self.args = args
else:
raise TypeError(f"Launcher args must be provided as a list, received: '{type(args)}'")
else:
self.args = []
def _config_ini_to_dict(self, config_file):
"""
Converts an .ini config file to a dict of dicts, then returns it.
:param config_file: string representing the file path to the .ini file.
:return: dict of dicts containing the section & keys from the .ini file,
otherwise raises a SetupError.
"""
config_dict = {}
user_profile_directory = os.path.expanduser('~').replace(os.sep, '/')
if not os.path.exists(config_file):
raise ly_test_tools.launchers.exceptions.SetupError(
f'Default file path not found: "{user_profile_directory}/ly_test_tools/devices.ini", '
f'got path: "{config_file}" instead. '
f'Please create the following file: "{user_profile_directory}/ly_test_tools/devices.ini" manually. '
f'Add device IP/ID info inside each section as well.\n'
'See ~/engine_root/dev/Tools/LyTestTools/README.txt for more info.')
config = ConfigParser()
config.read(config_file)
for section in config.sections():
config_dict[section] = dict(config.items(section))
return config_dict
def setup(self, backupFiles = True, launch_ap = True):
"""
Perform setup of this launcher, must be called before launching.
Subclasses should call its parent's setup() before calling its own code, unless it changes configuration files
For testing mobile or console devices, make sure you populate the config file located at:
~/ly_test_tools/devices.ini (a.k.a. %USERPROFILE%/ly_test_tools/devices.ini)
:param backupFiles: Bool to backup setup files
:return: None
"""
# Remove existing logs and dmp files before launching for self.save_project_log_files()
if os.path.exists(self.workspace.paths.project_log()):
for artifact in os.listdir(self.workspace.paths.project_log()):
try:
artifact_ext = os.path.splitext(artifact)[1]
if artifact_ext == '.dmp':
os.remove(os.path.join(self.workspace.paths.project_log(), artifact))
log.info(f"Removing pre-existing artifact {artifact} from calling Launcher.setup()")
# For logs, we are going to keep the file in existance and clear it to play nice with filesystem caching and
# our code reading the contents of the file
elif artifact_ext == '.log':
open(os.path.join(self.workspace.paths.project_log(), artifact), 'w').close() # clear it
log.info(f"Clearing pre-existing artifact {artifact} from calling Launcher.setup()")
except PermissionError:
log.warn(f'Unable to remove artifact: {artifact}, skipping.')
pass
# In case this is the first run, we will create default logs to prevent the logmonitor from not finding the file
os.makedirs(self.workspace.paths.project_log(), exist_ok=True)
default_logs = ["Editor.log", "Game.log"]
for default_log in default_logs:
default_log_path = os.path.join(self.workspace.paths.project_log(), default_log)
if not os.path.exists(default_log_path):
open(default_log_path, 'w').close() # Create it
# Wait for the AssetProcessor to be open.
if launch_ap:
timeout = 10
self.workspace.asset_processor.start()
ly_test_tools.environment.waiter.wait_for(
lambda: ly_test_tools.environment.process_utils.process_exists(
name="AssetProcessor", ignore_extensions=True),
exc=ly_test_tools.launchers.exceptions.SetupError(
f'AssetProcessor never opened after {timeout} seconds'),
timeout=timeout
)
self.workspace.asset_processor.wait_for_idle()
log.debug('AssetProcessor started from calling Launcher.setup()')
def backup_settings(self):
"""
Perform settings backup, storing copies of bootstrap, platform and user settings in the workspace's temporary
directory. Must be called after settings have been generated, in case they don't exist.
These backups will be lost after the workspace is torn down.
:return: None
"""
backup_path = self.workspace.settings.get_temp_path()
log.debug(f"Performing automatic backup of bootstrap, platform and user settings in path {backup_path}")
self.workspace.settings.backup_bootstrap_settings(backup_path)
self.workspace.settings.backup_platform_settings(backup_path)
self.workspace.settings.backup_shader_compiler_settings(backup_path)
def configure_settings(self):
"""
Perform settings configuration, must be called after a backup of settings has been created with
backup_settings(). Preferred ways to modify settings are:
self.workspace.settings.modify_bootstrap_setting()
self.workspace.settings.modify_platform_setting()
:return: None
"""
log.debug("No-op settings configuration requested")
pass
def restore_settings(self):
"""
Restores the settings backups created with backup_settings(). Must be called during teardown().
:return: None
"""
backup_path = self.workspace.settings.get_temp_path()
log.debug(f"Restoring backup of bootstrap, platform and user settings in path {backup_path}")
self.workspace.settings.restore_bootstrap_settings(backup_path)
self.workspace.settings.restore_platform_settings(backup_path)
self.workspace.settings.restore_shader_compiler_settings(backup_path)
def teardown(self):
"""
Perform teardown of this launcher, undoing actions taken by calling setup()
Subclasses should call its parent's teardown() after performing its own teardown.
:return: None
"""
self.workspace.asset_processor.stop()
self.save_project_log_files()
def save_project_log_files(self):
# type: () -> None
"""
Moves all .dmp and .log files from the project log folder into the artifact manager's destination
:return: None
"""
# A healthy large limit boundary
amount_of_log_name_collisions = 100
if os.path.exists(self.workspace.paths.project_log()):
for artifact in os.listdir(self.workspace.paths.project_log()):
if artifact.endswith('.dmp') or artifact.endswith('.log'):
self.workspace.artifact_manager.save_artifact(
os.path.join(self.workspace.paths.project_log(), artifact),
amount=amount_of_log_name_collisions)
def binary_path(self):
"""
Return this launcher's path to its binary file (exe, app, apk, etc).
Only required if the platform supports it.
:return: Complete path to the binary (if supported)
"""
raise NotImplementedError("There is no binary file for this launcher")
def start(self, backupFiles = True, launch_ap = True):
"""
Automatically prepare and launch the application
When called using "with launcher.start():" it will automatically call stop() when block exits
Subclasses should avoid overriding this method
:return: Application wrapper for context management, not intended to be called directly
"""
return _Application(self, backupFiles, launch_ap=launch_ap)
def _start_impl(self, backupFiles = True, launch_ap=True):
"""
Implementation of start(), intended to be called via context manager in _Application
:param backupFiles: Bool to backup settings files
:return None:
"""
self.setup(backupFiles, launch_ap=launch_ap)
self.launch()
def stop(self):
"""
Terminate the application and perform automated teardown, the opposite of calling start()
Called automatically when using "with launcher.start():"
:return None:
"""
self.kill()
self.ensure_stopped()
self.teardown()
def is_alive(self):
"""
Return whether the launcher is alive.
:return: True if alive, False otherwise
"""
raise NotImplementedError("is_alive is not implemented")
def launch(self):
"""
Launch the game, this method can perform a quick verification after launching, but it is not required.
:return None:
"""
raise NotImplementedError("Launch is not implemented")
def kill(self):
"""
Force stop the launcher.
:return None:
"""
raise NotImplementedError("Kill is not implemented")
def package(self):
"""
Performs actions required to create a launcher-package to be deployed for the given target.
This command will package without deploying.
This function is not applicable for PC, Mac, and ios.
Subclasses should override only if needed. The default behavior is to do nothing.
:return None:
"""
log.debug("No-op package requested")
pass
def wait(self, timeout=30):
"""
Wait for the launcher to end gracefully, raises exception if process is still running after specified timeout
"""
ly_test_tools.environment.waiter.wait_for(
lambda: not self.is_alive(),
exc=ly_test_tools.launchers.exceptions.WaitTimeoutError("Application is unexpectedly still active"),
timeout=timeout
)
def ensure_stopped(self, timeout=30):
"""
Wait for the launcher to end gracefully, if the process is still running after the specified timeout, it is
killed by calling the kill() method.
:param timeout: Timeout in seconds to wait for launcher to be killed
:return None:
"""
try:
ly_test_tools.environment.waiter.wait_for(
lambda: not self.is_alive(),
exc=ly_test_tools.launchers.exceptions.TeardownError("Application is unexpectedly still active"),
timeout=timeout
)
except ly_test_tools.launchers.exceptions.TeardownError:
self.kill()
def get_device_config(self, config_file, device_section, device_key):
"""
Takes an .ini config file path, .ini section name, and key for the value to search
inside of that .ini section. Returns a string representing a device identifier, i.e. an IP.
:param config_file: string representing the file path for the config ini file.
default is '~/ly_test_tools/devices.ini'
:param device_section: string representing the section to search in the ini file.
:param device_key: string representing the key to search in device_section.
:return: value held inside of 'device_key' from 'device_section' section,
otherwise raises a SetupError.
"""
config_dict = self._config_ini_to_dict(config_file)
section_dict = {}
device_value = ''
# Verify 'device_section' and 'device_key' are valid, then return value inside 'device_key'.
try:
section_dict = config_dict[device_section]
except (AttributeError, KeyError, ValueError) as err:
problem = ly_test_tools.launchers.exceptions.SetupError(
f"Could not find device section '{device_section}' from ini file: '{config_file}'")
six.raise_from(problem, err)
try:
device_value = section_dict[device_key]
except (AttributeError, KeyError, ValueError) as err:
problem = ly_test_tools.launchers.exceptions.SetupError(
f"Could not find device key '{device_key}' "
f"from section '{device_section}' in ini file: '{config_file}'")
six.raise_from(problem, err)
return device_value
class _Application(object):
"""
Context-manager for opening an application, enables using both "launcher.start()" and "with launcher.start()"
"""
def __init__(self, launcher, backupFiles = True, launch_ap = True):
"""
Called during both "launcher.start()" and "with launcher.start()"
:param launcher: launcher-object to manage
:return None:
"""
self.launcher = launcher
launcher._start_impl(backupFiles, launch_ap=launch_ap)
def __enter__(self):
"""
PEP-343 Context manager begin-hook
Runs at the start of "with launcher.start()"
:return None:
"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""
PEP-343 Context manager end-hook
Runs at the end of "with launcher.start()" block
:return None:
"""
self.launcher.stop()
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,77 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Wrapper to manage launching Lumberyard-created apps on OSX
"""
import logging
import os
import subprocess
import ly_test_tools.environment.waiter
import ly_test_tools.launchers.exceptions
from ly_test_tools.launchers.platforms.base import Launcher
log = logging.getLogger(__name__)
class MacLauncher(Launcher):
def __init__(self, workspace, args):
super(MacLauncher, self).__init__(workspace, args)
self._proc = None
log.debug("Initialized Mac Launcher")
def binary_path(self):
"""
Return full path to the launcher for this build's configuration and project
:return: full path to <project>.GameLauncher.exe
"""
assert self.workspace.project is not None, "Project is not configured in Workspace"
appname = f"{self.workspace.project}.GameLauncher"
return os.path.join(self.workspace.paths.build_directory(), f"{appname}.app", "Contents", "MacOS", appname)
def launch(self):
"""
Launch the executable and track the subprocess
:return: None
"""
command = [self.binary_path()] + self.args
self._proc = subprocess.Popen(command)
log.debug(f"Started Mac Launcher with command: {command}")
def kill(self):
"""
This is a hard kill, and then wait to make sure until it actually ended.
:return: None
"""
if self._proc is not None:
self._proc.kill()
ly_test_tools.environment.waiter.wait_for(
lambda: not self.is_alive(),
exc=ly_test_tools.launchers.exceptions.TeardownError(
f"Unable to terminate active Mac Launcher with process ID {self._proc.pid}"))
self._proc = None
log.debug("Mac Launcher terminated successfully")
def is_alive(self):
"""
Check the process to verify activity. Side effect of setting self._proc to None if it has ended.
:return: None
"""
if self._proc is None:
return False
else:
if self._proc.poll() is not None:
self._proc = None
return self._proc is not None
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,206 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Windows compatible launcher
"""
import logging
import os
import subprocess
import ly_test_tools.environment.waiter
import ly_test_tools.launchers.exceptions
from ly_test_tools.launchers.platforms.base import Launcher
from ly_test_tools.launchers.exceptions import TeardownError, ProcessNotStartedError
from tempfile import TemporaryFile
log = logging.getLogger(__name__)
class WinLauncher(Launcher):
def __init__(self, build, args):
super(WinLauncher, self).__init__(build, args)
self._proc = None
self._ret_code = None
self._tmpout = None
log.debug("Initialized Windows Launcher")
def binary_path(self):
"""
Return full path to the launcher for this build's configuration and project
:return: full path to <project>.GameLauncher.exe
"""
assert self.workspace.project is not None
return os.path.join(self.workspace.paths.build_directory(), f"{self.workspace.project}.GameLauncher.exe")
def setup(self, backupFiles = True, launch_ap = True):
"""
Perform setup of this launcher, must be called before launching.
Subclasses should call its parent's setup() before calling its own code, unless it changes configuration files
:param backupFiles: Bool to backup setup files
:return: None
"""
# Backup
if backupFiles:
self.backup_settings()
# Modify and re-configure
self.configure_settings()
super(WinLauncher, self).setup(launch_ap=launch_ap)
def launch(self):
"""
Launch the executable and track the subprocess
:return: None
"""
command = [self.binary_path()] + self.args
self._tmpout = TemporaryFile()
self._proc = subprocess.Popen(command, stdout=self._tmpout, stderr=self._tmpout, universal_newlines=True)
log.debug(f"Started Windows Launcher with command: {command}")
def get_output(self, encoding="utf-8"):
if self._tmpout is None:
raise ProcessNotStartedError("Process must be started before retrieving output")
self._tmpout.seek(0)
return self._tmpout.read().decode(encoding)
def teardown(self):
"""
Perform teardown of this launcher, undoing actions taken by calling setup()
Subclasses should call its parent's teardown() after performing its own teardown.
:return: None
"""
self.restore_settings()
super(WinLauncher, self).teardown()
def kill(self):
"""
This is a hard kill, and then wait to make sure until it actually ended.
:return: None
"""
if self._proc is not None:
self._proc.kill()
ly_test_tools.environment.waiter.wait_for(
lambda: not self.is_alive(),
exc=ly_test_tools.launchers.exceptions.TeardownError(
f"Unable to terminate active Windows Launcher with process ID {self._proc.pid}")
)
self._proc = None
self._ret_code = None
log.debug("Windows Launcher terminated successfully")
def is_alive(self):
"""
Check the process to verify activity. Side effect of setting self.proc to None if it has ended.
:return: None
"""
if self._proc is None:
return False
else:
if self._proc.poll() is not None:
self._ret_code = self._proc.poll()
self._proc = None
return False
return True
def get_pid(self):
# type: () -> int or None
"""
Returns the pid of the launcher process if it exists, else it returns None
:return: process id or None
"""
if self._proc:
return self._proc.pid
return None
def get_returncode(self):
# type: () -> int or None
"""
Returns the returncode of the launcher process if it exists, else return None.
The returncode attribute is set when the process is terminated.
:return: The returncode of the launcher's process
"""
if self._proc:
return self._proc.poll()
else:
return self._ret_code
def check_returncode(self):
# type: () -> None
"""
Checks the returncode of the launcher if it exists. Raises a CrashError if the returncode is non-zero. Returns
None otherwise. This function must be called after exiting the launcher properly and NOT using its provided
teardown(). Provided teardown() will always return a non-zero returncode and should not be checked.
:return: None
"""
return_code = self.get_returncode()
if return_code != 0:
log.error(f"Launcher exited with non-zero return code: {return_code}")
raise ly_test_tools.launchers.exceptions.CrashError()
return None
def configure_settings(self):
"""
Configures system level settings and syncs the launcher to the targeted console IP.
:return: None
"""
# Update settings
host_ip = '127.0.0.1'
self.workspace.settings.modify_bootstrap_setting("sys_game_folder", self.workspace.project)
self.workspace.settings.modify_bootstrap_setting("remote_ip", host_ip)
self.workspace.settings.modify_bootstrap_setting("wait_for_connect", 1)
self.workspace.settings.modify_bootstrap_setting("white_list", host_ip)
self.workspace.settings.modify_platform_setting("r_AssetProcessorShaderCompiler", 1)
self.workspace.settings.modify_platform_setting("r_ShaderCompilerServer", host_ip)
self.workspace.settings.modify_platform_setting("log_RemoteConsoleAllowedAddresses", host_ip)
class DedicatedWinLauncher(WinLauncher):
def binary_path(self):
"""
Return full path to the dedicated server launcher for the build directory.
:return: full path to <project>Launcher_Server.exe
"""
assert self.workspace.project is not None, (
'Project cannot be NoneType - please specify a project name string.')
return os.path.join(f"{self.workspace.paths.build_directory()}",
f"{self.workspace.project}.ServerLauncher.exe")
class WinEditor(WinLauncher):
def __init__(self, build, args):
super(WinEditor, self).__init__(build, args)
self.args.append("--regset=\"/Amazon/Settings/EnableSourceControl=false\"")
def binary_path(self):
"""
Return full path to the Editor for this build's configuration and project
:return: full path to Editor.exe
"""
assert self.workspace.project is not None
return os.path.join(self.workspace.paths.build_directory(), "Editor.exe")
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,281 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Functions to aid in monitoring log files being actively written to for a set of lines to read for.
"""
import logging
import os
import re
import ly_test_tools.environment.waiter as waiter
import ly_test_tools.launchers.platforms.base
logger = logging.getLogger(__name__)
LOG_MONITOR_INTERVAL = 0.1 # seconds
class LogMonitorException(Exception):
"""Custom exception class for errors related to log_monitor.py"""
pass
def check_exact_match(line, expected_line):
"""
Uses regular expressions to find an exact (not partial) match for 'expected_line' in 'line', i.e.
in the example below it matches 'foo' and succeeds:
line value: '66118.999958 - INFO - [MainThread] - ly_test_tools.lumberyard.asset_processor - foo'
expected_line: 'foo'
:param line: The log line string to search,
i.e. '9189.9998188 - INFO - [MainThread] - example.tests.test_system_example - Log Monitoring test 1'
:param expected_line: The exact string to match when searching the line param,
i.e. 'Log Monitoring test 1'
:return: An exact match for the string if one is found, None otherwise.
"""
# Look for either start of line or whitespace, then the expected_line, then either end of the line or whitespace.
# This way we don't partial match inside of a string. So for example, 'foo' matches 'foo bar' but not 'foobar'
regex_pattern = re.compile("(^|\\s){}($|\\s)".format(re.escape(expected_line)))
if regex_pattern.search(line) is not None:
return expected_line
return None
class LogMonitor(object):
def __init__(self, launcher, log_file_path, log_creation_max_wait_time=5):
"""
Log monitor object for monitoring a single log file for expected or unexpected line values.
Requires a launcher class & valid log file path.
:param launcher: Launcher class object that opens a locally-accessible log file to write to.
:param log_file_path: string representing the path to the file to open.
:param log_creation_max_wait_time: max time to wait in seconds for log to exist
"""
self.unexpected_lines_found = []
self.expected_lines_not_found = []
self.launcher = launcher
self.log_file_path = log_file_path
self.py_log = ""
self.log_creation_max_wait_time = log_creation_max_wait_time
def monitor_log_for_lines(self,
expected_lines=None,
unexpected_lines=None,
halt_on_unexpected=False,
timeout=30):
"""
Monitor for expected or unexpected lines for the log file attached to this LogMonitor object.
Returns True on success or raises LogMonitorException on failure.
Will search for X seconds where X is the value of the timeout parameter.
:param expected_lines: list of strings that the user wants to find in the self.log_file_path file.
:param unexpected_lines: list of strings that must not be present in the self.log_file_path file.
:param halt_on_unexpected: boolean to determine whether to raise LogMonitorException on the first
unexpected line found (True) or not (False)
:param timeout: int time in seconds to search for expected/unexpected lines before raising LogMonitorException.
:return: True if monitoring succeeded, raises a LogMonitorException otherwise.
"""
# Validation checks before monitoring the log file writes.
launcher_class = ly_test_tools.launchers.platforms.base.Launcher
if not os.path.exists(self.log_file_path):
raise LogMonitorException(
"Referenced self.log_file_path file does not exist: {}".format(self.log_file_path))
if not isinstance(self.launcher, launcher_class):
raise LogMonitorException(
"Referenced launcher type: '{}' is not a valid launcher class. Must be of type: '{}'".format(
type(self.launcher), launcher_class))
if not expected_lines and not unexpected_lines:
logger.warning("Requested log monitoring for no lines, aborting")
return
# Enforce list typing for expected_lines & unexpected_lines
if unexpected_lines is None:
unexpected_lines = []
if expected_lines is None:
expected_lines = []
logger.warning(
"Requested log monitoring without providing any expected lines. "
"Log monitoring will continue for '{}' seconds to search for unexpected lines.".format(timeout))
if type(expected_lines) is not list or type(unexpected_lines) is not list:
raise LogMonitorException(
"expected_lines or unexpected_lines must be 'list' type variables. "
"Got types: type(expected_lines) == {} & type(unexpected_lines) == {}".format(
type(expected_lines), type(unexpected_lines)))
# Make sure the expected_lines don't have any common lines with unexpected_lines
expected_lines_in_unexpected = [line for line in unexpected_lines if line in expected_lines]
if expected_lines_in_unexpected:
raise LogMonitorException("Found unexpected_lines in expected_lines:\n{}".format("\n".join(expected_lines_in_unexpected)))
unexpected_lines_in_expected = [line for line in expected_lines if line in unexpected_lines]
if unexpected_lines_in_expected:
raise LogMonitorException("Found expected_lines in unexpected_lines:\n{}".format("\n".join(unexpected_lines_in_expected)))
# Log file is now opened by our process, start monitoring log lines:
self.py_log = ""
try:
logger.debug("Monitoring log file in '{}' ".format(self.log_file_path))
with open(self.log_file_path, mode='r') as log:
logger.info(
"Monitoring log file '{}' for '{}' seconds".format(self.log_file_path, timeout))
search_expected_lines = expected_lines.copy()
search_unexpected_lines = unexpected_lines.copy()
waiter.wait_for( # Sets the values for self.unexpected_lines_found & self.expected_lines_not_found
lambda: self._find_lines(log, search_expected_lines, search_unexpected_lines, halt_on_unexpected),
timeout=timeout,
interval=LOG_MONITOR_INTERVAL)
except AssertionError: # Raised by waiter when timeout is reached.
logger.warning(f"Timeout of '{timeout}' seconds was reached, log lines may not have been found")
# exception will be raised below by _validate_results with failure analysis
logger.info("Python log output:\n" + self.py_log)
logger.info(
"Finished log monitoring for '{}' seconds, validating results.\n"
"expected_lines_not_found: {}\n unexpected_lines_found: {}".format(
timeout, self.expected_lines_not_found, self.unexpected_lines_found))
return self._validate_results(self.expected_lines_not_found, self.unexpected_lines_found, expected_lines, unexpected_lines)
def _find_expected_lines(self, line, expected_lines):
"""
Checks for any matches between the 'line' string and strings in the 'expected_lines' list.
Removes expected_lines strings that are found from the main expected_lines list and returns the remaining
expected_lines list values.
:param line: string from a TextIO or BinaryIO file object being read line by line.
:param expected_lines: list of strings to search for in each read line from the log file.
:return: updated expected_lines list of strings after parsing the value of the line param.
"""
expected_lines_to_remove = []
for expected_line in expected_lines:
searched_line = check_exact_match(line, expected_line)
if expected_line == searched_line:
logger.debug("Found expected line: {} from line: {}".format(expected_line, line))
expected_lines_to_remove.append(expected_line)
for expected_line in expected_lines_to_remove:
expected_lines.remove(expected_line)
return expected_lines
def _find_unexpected_lines(self, line, unexpected_lines, halt_on_unexpected):
"""
Checks for any matches between the 'line' string and strings in the 'unexpected_lines' list.
Removes unexpected_lines strings that are found from the main unexpected_lines list and adds them to the
unexpected_lines_found list.
:param line: string from a TextIO or BinaryIO file object being read line by line.
:param unexpected_lines: list of strings to search for in each read line from the log file.
:param halt_on_unexpected: boolean to determine whether to raise ValueError on the first
unexpected line found (True) or not (False)
:return: unexpected_lines_found from the unexpected_lines searched for in the current log line.
"""
unexpected_lines_found = self.unexpected_lines_found
unexpected_lines_to_remove = []
for unexpected_line in unexpected_lines:
searched_line = check_exact_match(line, unexpected_line)
if unexpected_line == searched_line:
logger.debug("Found unexpected line: {} from line: {}".format(unexpected_line, line))
if halt_on_unexpected:
raise LogMonitorException(
"Unexpected line appeared: {} from line: {}".format(unexpected_line, line))
unexpected_lines_found.append(unexpected_line)
unexpected_lines_to_remove.append(unexpected_line)
for unexpected_line in unexpected_lines_to_remove:
unexpected_lines.remove(unexpected_line)
return unexpected_lines_found
def _validate_results(self, expected_lines_not_found, unexpected_lines_found, expected_lines, unexpected_lines):
"""
Parses the values in the expected_lines_not_found & unexpected_lines_found lists.
If any expected lines were NOT found or unexpected lines WERE found, a LogMonitorException will be raised.
The LogMonitorException message will detail the values that triggered the error.
:param expected_lines_not_found: list of strings for expected lines that did NOT appeared in the log file.
:param unexpected_lines_found: list of strings for unexpected lines that DID appear in the log file.
:return: True if results are validated, but raises a LogMonitorException if any errors found.
"""
failure_found = False
fail_message = "While monitoring file '{}':\n".format(self.log_file_path)
expected_line_failures = ''
expected_lines_found = [line for line in expected_lines if line not in expected_lines_not_found]
# Find out if any error strings need to be constructed.
if expected_lines_not_found or unexpected_lines_found:
failure_found = True
# Add the constructed error strings and raise a LogMonitorException if any are found.
expected_lines_info = ""
for line in expected_lines:
if line in expected_lines_found:
expected_lines_info += "[ FOUND ] {}\n".format(line)
else:
expected_lines_info += "[ NOT FOUND ] {}\n".format(line)
logger.info("LogMonitor Result:\n"
"--- Expected lines ---\n"
f"{expected_lines_info}"
f"Found {len(expected_lines_found)}/{len(expected_lines)} expected lines")
if failure_found:
if expected_lines_not_found:
expected_line_failures = "\n".join(expected_lines_not_found)
logger.error('Following expected lines *NOT FOUND*:\n{}'.format(expected_line_failures))
fail_message += "Failed to find expected line(s):\n{}".format(expected_line_failures)
if unexpected_lines_found:
unexpected_line_failures = "\n".join(unexpected_lines_found)
logger.error('Following unexpected lines *FOUND*:\n{}'.format(unexpected_line_failures))
fail_message += "Additionally, " if expected_line_failures else ""
fail_message += "Found unexpected line(s):\n{}".format(unexpected_line_failures)
raise LogMonitorException(fail_message)
return True
def _find_lines(self, log, expected_lines, unexpected_lines, halt_on_unexpected):
"""
Given a list of strings in expected_lines, unexpected_lines, and a log file, read every line in the log file,
and make sure all expected_lines strings appear & no unexpected_lines strings appear in the log file.
NOTE: This loop will only end when a launcher process ends or if used as a callback function (i.e. waiter).
:param log: TextIO or BinaryIO file object to read lines from.
:param expected_lines: list of strings to search for in each read line from the log file.
:param unexpected_lines: list of strings that must not be present in the log_file_path file.
:param halt_on_unexpected: boolean to determine whether to raise LogMonitorException on the first
unexpected line found (True) or not (False)
:return: (wait_condition) Whether the log processing has finished(True: finished, False: unfinished)
sets self.unexpected_lines_found & self.expected_lines_not_found
"""
log_filename = os.path.basename(self.log_file_path)
def process_line(line):
self.py_log += ("|%s| %s\n" % (log_filename, line))
expected_lines_not_found = self._find_expected_lines(line, expected_lines)
unexpected_lines_found = self._find_unexpected_lines(line, unexpected_lines, halt_on_unexpected)
self.unexpected_lines_found = unexpected_lines_found
self.expected_lines_not_found = expected_lines_not_found
# To avoid race conditions, we will check *before reading*
# If in the mean time the file is closed, we will make sure we read everything by issuing an extra call
# by returning the previous alive state
process_runing = self.launcher.is_alive()
for line in log:
line = line[:-1] # remove /n
process_line(line)
return not process_runing # Will loop until the process ends
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,270 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import logging
import re
from typing import List, Optional, Dict, Generator, Tuple
import time
logger = logging.getLogger(__name__)
class APOutputParser:
"""
Asset Processor Parser to extract information from generic asset processor output.
Immutable.
"""
_SEPARATOR = ": "
_NONE_LINE = "none"
# Does support multiple output runs
_NEW_RUN_LINE = "AzFramework File Logging New Run"
# Regular expression constants and keys used for looking up information later.
_RE_INT_LINES = ( # Extract an integer
(re.compile(r"^Number of Assets Successfully Processed: (\d*)."), "Successes"),
(re.compile(r"^Number of Assets Failed to Process: (\d*)."), "Failures"),
(re.compile(r"^Number of Warnings Reported: (\d*)."), "Warnings"),
(re.compile(r"^Number of Errors Reported: (\d*)."), "Errors"),
(re.compile(r"^Control Port: (\d*)"), "Control Port"),
(re.compile(r"^Listening Port: (\d*)"), "Listening Port"),
)
_RE_FLOAT_LINES = ((re.compile(r"^Total Assets Processing Time: (\d*\.?\d*)s"), "Time"),) # Extract a float
_RE_TUPLE_LINES = ( # Extract a tuple of integers
(re.compile(r"^Builder optimization: (\d*) / (\d*)"), "Full Analysis"),
)
_RE_STR_LINES = ( # Extract a string
(re.compile(r"^AssetBuilder: Source = (.*)"), "Source"),
(re.compile(r"^AssetBuilder: Platforms = (.*)"), "Platforms"),
)
# JobLogs log both errors and warnings on the same line. Handled separately
_RE_ERRORS_WARNINGS = re.compile(r"^S: (\d*)\D*(\d*)")
def __init__(self, raw_output: str) -> None:
self._runs = []
self.log_type = "Output"
self._parse_lines(raw_output)
@property
def file_path(self) -> str:
"""Parsed log file path"""
return self._file_path
@property
def runs(self) -> List[Dict]:
"""
Returns all runs from the log as a list of dictionaries.
Dictionary keys are as follows:
["Lines"]: [str] - All lines from the log for that run.
["Timestamps"]: [int] - Timestamp from each entry in Lines list
["Errors"]: int - the number of errors produced.
["Warnings"]: int - the number of warnings produced.
["Successes"]: int - the recorded number of successful assets processed.
["Failures"]: int - the recorded number of assets that failed to process.
["Control Port"]: int - port for the control socket recorded in the ap gui log
["Listening Port"]: int - port asset processor is listening for incoming connections on,
written to the AP log for both batch and gui
["Time"]: float - the recorded time it took for the AP to complete the run.
["Full Analysis"]: (int, int) - A tuple where (x, y) is associated with the line:
"x / y files required full analysis..."
["Source"]: str - (JobLogs only) the source recorded for the build job.
["Platforms"]: str - (JobLogs only) The platforms recorded for the job.
"""
return self._runs
def get_line_type(self, line: str) -> str:
"""Parses the line type from a trimmed log line. See: APLogParser._trim_line(self, line)"""
split = line.split(self._SEPARATOR, 1)
if len(split) > 1:
return split[0]
return ""
def remove_line_type(self, line: str) -> str:
"""Removes the line type from a trimmed log line. See: APLogParser._trim_line(self, line)"""
split = line.split(self._SEPARATOR, 1)
if len(split) > 1:
return split[1]
return ""
# fmt:off
def get_lines(self, run: Optional[int], contains: Optional[List[str] or str] = None,
regex: Optional[str] = None) -> Generator[str, None, None] or Generator[re.Match, None, None]:
# fmt:on
"""
Iterate the lines in the log by specifying a run index (or None for all).
Filter results returned using the [contains] string.
Or return a regex match object via the [regex] string.
Prioritizes [regex] searching over [contains] searching.
:param run: The index of the run to search. If None, all runs are searched
:param contains: A string (or list of strings) to search for in the log
:param regex: A regular expression string to use to search the log.
:return: Each line that matches
"""
runs_to_search = []
if run is None:
runs_to_search = self._runs
else:
runs_to_search.append(self._runs[run])
for run in runs_to_search:
for line in run["Lines"]:
if regex is not None:
match = re.match(regex, line)
if match:
yield match
elif contains is not None:
if type(contains) == str:
contains = [contains]
# List comprehension returns empty list if all search strings are in the line
if not [True for search_string in contains if search_string not in line]:
yield line
else:
yield line
def _parse_lines(self, all_lines: str):
current_run = self._create_log_dict()
for i, line in enumerate(all_lines):
trimmed, timestamp = self._trim_line(line)
line_type = self.get_line_type(trimmed)
if trimmed and line_type:
if line_type != self._NONE_LINE:
# not a "None" line, digest the line
self._digest_line(trimmed, current_run, timestamp)
elif self._NEW_RUN_LINE in all_lines[i - 1] and current_run["Lines"]:
# Hit the end of a "run" in the log. Store it, clear it and continue
self._runs.append(current_run)
current_run = self._create_log_dict()
if current_run["Lines"]:
self._runs.append(current_run)
def _trim_line(self, line: str) -> str:
""" For raw input in APOutputParser simply returns the line (Already "trimmed")
APLogParser's implementation trims a raw log line to remove the first 3 sections when using a log
See APLogParser : _trim_line below
"""
return line, int(round(time.time() * 1000))
def _digest_line(self, trimmed_line: str, current_run: Dict, line_timestamp: int) -> None:
"""Extracts relevant information (if present) and adds line to list of all lines"""
line_type = self.get_line_type(trimmed_line)
# Store all lines under ["Lines"]
if line_type in current_run.keys():
current_run[line_type].append(trimmed_line.split(self._SEPARATOR)[1])
current_run["Lines"].append(trimmed_line)
current_run["Timestamps"].append(line_timestamp)
trimmed_line = self.remove_line_type(trimmed_line)
# Parse useful data if present. Parsing rules declared in static constants.
for pattern, run_key in self._RE_INT_LINES:
# Look for integer data (Errors, warnings... etc.)
result = pattern.match(trimmed_line)
if result:
current_run[run_key] = int(result.groups()[0])
return
for pattern, run_key in self._RE_FLOAT_LINES:
# Look for float data (Time... etc.)
result = pattern.match(trimmed_line)
if result:
current_run[run_key] = float(result.groups()[0])
return
for pattern, run_key in self._RE_TUPLE_LINES:
# Look for tuple data ("x / y files required full analysis"... etc.)
result = pattern.match(trimmed_line)
if result:
current_run[run_key] = (int(result.groups()[0]), int(result.groups()[1]))
return
for pattern, run_key in self._RE_STR_LINES:
# Look for string data (Source, Platform... etc.)
result = pattern.match(trimmed_line)
if result:
current_run[run_key] = result.groups()[0]
return
# JobLog Errors / Warnings are reported on a single line. Handle separately
result = self._RE_ERRORS_WARNINGS.match(trimmed_line)
if result and len(result.groups()) == 2:
current_run["Errors"] = int(result.groups()[0])
current_run["Warnings"] = int(result.groups()[1])
@staticmethod
def _create_log_dict() -> Dict:
"""Creates a dictionary ready to used to store log information"""
return {
"Lines": [],
"Errors": None,
"Successes": None,
"Warnings": None,
"Time": None,
"Full Analysis": None,
"Source": None,
"Platforms:": None,
"Timestamps": []
}
class APLogParser(APOutputParser):
"""
Asset Processor Log Parser to extract information from an asset processor log.
Immutable.
"""
_SEPARATOR = "~~"
_NONE_LINE = "none"
_NEW_RUN_LINE = "AzFramework File Logging New Run"
def __init__(self, file_path: str, raw_output: str = None) -> None:
self._runs = []
self._file_path = file_path
self.log_type = None
if "JobLogs" in file_path:
self.log_type = "JobLog"
elif "AP_Batch" in file_path:
self.log_type = "Batch"
elif "AP_GUI" in file_path:
self.log_type = "GUI"
self._parse_file()
@property
def file_path(self) -> str:
"""Parsed log file path"""
return self._file_path
def _parse_file(self) -> None:
"""
Parses the APLogParser's file and populates a "run" for every AP logging run present.
"""
try:
logger.info(f"Parsing log file file: {self._file_path}")
with open(self._file_path, "r") as log_file:
all_lines = log_file.readlines()
self._parse_lines(all_lines)
except OSError:
logger.error(f"Error opening file: {self._file_path}")
self._runs = []
def _trim_line(self, line: str) -> Tuple[str, int]:
"""Trims a raw log line to remove the first 3 sections
example:
~~1581617216532~~1~~0000000000002540~~AssetProcessor~~Loading (Gem) Module 'GemRegistry'...
trims to:
AssetProcessor~~Loading (Gem) Module 'GemRegistry'...
where the trimmed line is: <line-type>~~<line-contents>
"""
split = line.strip().split(self._SEPARATOR, 4)
if len(split) > 4:
return split[4], int(split[1])
return "", 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Library of functions to support reading, modifying and writing to
AssetProcessorPlatformConfig.ini
"""
import logging
from ly_test_tools.lumberyard import ini_configuration_util as ini
import os.path as path
logger = logging.getLogger(__name__)
AssetProcessorConfig = "AssetProcessorPlatformConfig.ini"
def platform_exists(config_ini_path, platform):
"""
Checks to see if a specific Platform Key is in Platforms Section
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:param platform: Name of the Platform Key that you're checking exists
:return: The boolean value of the existance of the platform
"""
logger.debug("Checking for Platform '{0}' in Platforms Section of '{1}"
.format(platform, AssetProcessorConfig))
file_location = path.join(config_ini_path, AssetProcessorConfig)
assert ini.check_section_exists(file_location, 'Platforms'), \
'Platforms section does not exist in {0}'.format(file_location)
return ini.check_key_exists(path.join(config_ini_path, AssetProcessorConfig), 'Platforms', platform)
def is_platform_enabled(config_ini_path, platform):
"""
Checks to see if a specific Platform Key is enabled in Platforms Section
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:param platform: Name of the Platform that you're checking is enabled.
See asset_processor.SUPPORTED_PLATFORMS for listed of supported platforms
:return: The boolean value of the enabled state of the platform
"""
logger.debug("Checking if Platform '{0}' is enabled in Platform Section of '{1}"
.format(platform, AssetProcessorConfig))
file_location = path.join(config_ini_path, AssetProcessorConfig)
assert ini.check_section_exists(file_location, 'Platforms'), \
'Platforms section does not exist in {0}'.format(file_location)
enabled = str(ini.get_string_value(file_location, 'Platforms', platform)) == 'enabled'
return enabled
def enable_platform(config_ini_path, platform):
"""
Enables a specific Platform Key is in Platforms Section
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:param platform: Name of the Platform Key that you're checking exists
See asset_processor.SUPPORTED_PLATFORMS for listed of supported platforms
:assert: Assert if the platform is not enabled
:return: None
"""
logger.debug("Enabling platform '{0}' in '{1}'".format(platform, AssetProcessorConfig))
file_location = path.join(config_ini_path, AssetProcessorConfig)
ini.add_key(file_location, 'Platforms', platform, 'enabled')
assert is_platform_enabled(config_ini_path, platform), "Platform '{0}' failed to enable in '{1}'"\
.format(platform, AssetProcessorConfig)
def enable_all_platforms(config_ini_path):
"""
Enable all supported platforms in the Platforms Section
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
logger.debug("Enabling all supported platforms in '{0}'.".format(AssetProcessorConfig))
for platform in SUPPORTED_PLATFORMS:
enable_platform(config_ini_path, platform)
logger.debug("All supported platforms have been enabled in '{0}'.".format(AssetProcessorConfig))
def disable_platform(config_ini_path, platform):
"""
Disables a specific Platform Key is in Platforms Section
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:param platform: Name of the Platform Key that you're checking exists.
See asset_processor_config_util.SUPPORTED_PLATFORMS for listed of supported platforms
:assert: Assert if the platform is not disabled
:return: None
"""
logger.debug("Disabling platform '{0}' in '{1}'".format(platform, AssetProcessorConfig))
file_location = path.join(config_ini_path, AssetProcessorConfig)
ini.add_key(file_location, 'Platforms', platform, 'disabled')
assert not is_platform_enabled(config_ini_path, platform), "Platform '{0}' failed to disable in '{1}'"\
.format(platform, AssetProcessorConfig)
def disable_all_platforms(config_ini_path):
"""
Disable all supported platforms in the Platforms Section
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
logger.debug("Disabling all platforms in '{0}'".format(AssetProcessorConfig))
for platform in SUPPORTED_PLATFORMS:
disable_platform(config_ini_path, platform)
logger.debug("All supported platforms have been disabled in '{0}'.".format(AssetProcessorConfig))
def enable_scanfolder_engine(config_ini_path):
"""
Enable Asset Processor scanning on the Engine folder
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
section = 'ScanFolder Engine'
logger.debug("Enabling Asset Processor scanning of the Engine folder")
if not ini.check_section_exists(file_location, section):
ini.add_section(file_location, section)
ini.add_key(file_location, section, 'watch', r'@ENGINEROOT@/Engine')
ini.add_key(file_location, section, 'recursive', '1')
ini.add_key(file_location, section, 'order', '20000')
def disable_scanfolder_engine(config_ini_path):
"""
Disable Asset Processor scanning on the Engine folder
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
section = 'ScanFolder Engine'
logger.debug("Disabling Asset Processor scanning of the Engine folder")
ini.delete_section(file_location, section)
def enable_scanfolder_editor(config_ini_path):
"""
Enable Asset Processor scanning on the editor folder
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
section = 'ScanFolder Editor'
logger.debug("Enabling Asset Processor scanning of the Editor folder")
if not ini.check_section_exists(file_location, section):
ini.add_section(file_location, section)
ini.add_key(file_location, section, 'watch', r'@ENGINEROOT@/Editor')
ini.add_key(file_location, section, 'output', 'editor')
ini.add_key(file_location, section, 'recursive', '1')
ini.add_key(file_location, section, 'order', '30000')
ini.add_key(file_location, section, 'include', 'tools,renderer')
def disable_scanfolder_editor(config_ini_path):
"""
Disable Asset Processor scanning on the Engine folder
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
section = 'ScanFolder Editor'
logger.debug("Disabling Asset Processor scanning of the Editor folder")
ini.delete_section(file_location, section)
def enable_scanfolder_root(config_ini_path):
"""
Enable Asset Processor scanning on the Root folder
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
section = 'ScanFolder Root'
logger.debug("Enabling Asset Processor scanning of the Root folder")
if not ini.check_section_exists(file_location, section):
ini.add_section(file_location, section)
ini.add_key(file_location, section, 'watch', r'@ROOT@')
ini.add_key(file_location, section, 'recursive', '0')
ini.add_key(file_location, section, 'order', '10000')
def disable_scanfolder_root(config_ini_path):
"""
Disable Asset Processor scanning on the Root folder
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
section = 'ScanFolder Root'
logger.debug("Disabling Asset Processor scanning of the Root folder")
ini.delete_section(file_location, section)
def update_pattern(config_ini_path, pattern, key, value):
"""
Update a RC pattern's behavior options with the provided key and value.
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:param pattern: The RC pattern to update
:param key: The key to update
:param value: The value to set the key to
:return: None
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
rc_pattern = 'RC ' + pattern
logger.debug("Modifying pattern for '{0}'.".format(rc_pattern))
ini.add_key(file_location, rc_pattern, key, value)
def get_pattern_key(config_ini_path, pattern, key):
"""
Get the value of a RC pattern's behavior options key value
:param config_ini_path: The file path to the location of AssetProcessorPlatformConfig.ini
:param pattern: The RC pattern to retrieve a key from
:param key: The key to retrieve
:return: value of RC pattern key value
"""
file_location = path.join(config_ini_path, AssetProcessorConfig)
rc_pattern = 'RC ' + pattern
logger.debug("Retrieving the key '{0}' from pattern '{1}'.".format(key, rc_pattern))
return ini.get_string_value(file_location, rc_pattern, key)
@@ -0,0 +1,53 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import logging
import os
import subprocess
import ly_test_tools
from ly_test_tools.environment.process_utils import kill_processes_named as kill_processes_named
logger = logging.getLogger(__name__)
def start_asset_processor(bin_dir):
"""
Starts the AssetProcessor from the given bin directory. Raises a RuntimeError if the process fails.
:param bin_dir: The bin directory from which to launch the AssetProcessor executable.
:return: A subprocess.Popen object for the AssetProcessor process.
"""
os.chdir(bin_dir)
asset_processor = subprocess.Popen(['AssetProcessor.exe'])
return_code = asset_processor.poll()
if return_code is not None and return_code != 0:
logger.error("Failed to start AssetProcessor")
raise RuntimeError("AssetProcessor exited with code {}".format(return_code))
else:
logger.info("AssetProcessor is running")
return asset_processor
def kill_asset_processor():
"""
Kill the AssetProcessor and all its related processes.
:return: None
"""
kill_processes_named('AssetProcessor_tmp', ignore_extensions=True)
kill_processes_named('AssetProcessor', ignore_extensions=True)
kill_processes_named('AssetProcessorBatch', ignore_extensions=True)
kill_processes_named('AssetBuilder', ignore_extensions=True)
kill_processes_named('rc', ignore_extensions=True)
@@ -0,0 +1,334 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Small library of functions to manipulate ini configuration files.
Please see INI Specification for in depth explanations on parameter terms.
"""
import logging
from configparser import ConfigParser
logger = logging.getLogger(__name__)
def check_section_exists(file_location, section):
"""
Searches an INI Configuration file for the existance of a section
:param file_location: The file to get a key value from
:param section: The section to find the key value
:return: The boolean value of whether or not the section exists
"""
config = ConfigParser()
config.read(file_location)
return config.has_section(section)
def check_key_exists(file_location, section, key):
"""
Searches an INI Configuration file for the existance of a section & key
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:return: The boolean value of whether or not the key exists
"""
config = ConfigParser()
config.read(file_location)
return config.has_option(section, key)
def get_string_value(file_location, section, key):
"""
Searches an INI Configuration file for a section & key and returns it as a string.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:return: The string value retained in the key
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
return config.get(section, key)
else:
assert False, "Was unable to find the key. Please verify the existance of the section '{0}' and key '{1}"\
.format(section, key)
def get_boolean_value(file_location, section, key):
"""
Searches an INI Configuration file for a section & key and returns it as a string.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:return: The boolean value retained in the key
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
return config.getboolean(section, key)
else:
assert False, "Was unable to find the key. Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def get_integral_value(file_location, section, key):
"""
Searches an INI Configuration file for a section & key and returns it as a string.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:return: The boolean value retained in the key
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
return config.getint(section, key)
else:
assert False, "Was unable to find the key. Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def get_float_value(file_location, section, key):
"""
Searches an INI Configuration file for a section & key and returns it as a string.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:return: The boolean value retained in the key
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
return config.getfloat(section, key)
else:
assert False, "Was unable to find the key. Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def check_string_value(file_location, section, key, expected):
"""
Compare the string contained in a key against expected.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:param expected: They expected value to compare to
:assert: If the values do not match
:return: None
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
actual = get_string_value(file_location, section, key)
assert actual == expected, "The value of the '{0}' key in the '{1}' section was '{2}'" \
"and did not match the expected value of {3}".format(key, section, actual, expected)
else:
assert False, "Was unable to find the key to do a comparison. " \
"Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def check_boolean_value(file_location, section, key, expected):
"""
Compare the boolean contained in a key against expected.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:param expected: They expected value to compare to
:assert: If the values do not match
:return: None
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
actual = get_boolean_value(file_location, section, key)
assert actual == expected, "The value of the '{0}' key in the '{1}' section was '{2}'" \
"and did not match the expected value of {3}".format(key, section, actual, expected)
else:
assert False, "Was unable to find the key to do a comparison. " \
"Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def check_integral_value(file_location, section, key, expected):
"""
Compare the integral contained in a key against expected.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:param expected: They expected value to compare to
:assert: If the values do not match
:return: None
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
actual = get_integral_value(file_location, section, key)
assert actual == expected, "The value of the '{0}' key in the '{1}' section was '{2}'" \
"and did not match the expected value of {3}".format(key, section, actual, expected)
else:
assert False, "Was unable to find the key to do a comparison. " \
"Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def check_float_value(file_location, section, key, expected):
"""
Compare the float contained in a key against expected.
:param file_location: The file to get a key value from
:param section: The section to find the key value
:param key: The key that can contain a value to retrieve
:param expected: They expected value to compare to
:assert: If the values do not match
:return: None
"""
if check_key_exists(file_location, section, key):
config = ConfigParser()
config.read(file_location)
actual = get_float_value(file_location, section, key)
assert actual == expected, "The value of the '{0}' key in the '{1}' section was '{2}'" \
"and did not match the expected value of {3}".format(key, section, actual, expected)
else:
assert False, "Was unable to find the key to do a comparison. " \
"Please verify the existance of the section '{0}' and key '{1}" \
.format(section, key)
def add_section(file_location, section):
"""
Add section to the configuration file provided
:param file_location: The file to get a key value from
:param section: The section to add
:assert: If the the section does not exist in the file after attempting to add it
:return: None
"""
config = ConfigParser()
config.read(file_location)
config.add_section(section)
with open(file_location, 'w') as configfile:
config.write(configfile)
assert check_section_exists(file_location, section), \
"Section '{0}' failed to add to the configuration file '{1}'".format(section, file_location)
def add_key(file_location, section, key, value=''):
"""
Add key to the section in the configuration file provided
:param file_location: The file to get a key value from
:param section: The section to add the key value
:param key: The section to add the key value
:param value: The value to set the key to
:assert: If the the key does not exist in the file after attempting to add it
:return: None
"""
logger.debug("Section exists: {0}".format(check_section_exists(file_location, section)))
assert check_section_exists(file_location, section), \
"Cannot add a key to section '{0}' since it does not exist in configuration file '{1}'".format(section,
file_location)
config = ConfigParser()
config.read(file_location)
config.set(section, key, value)
with open(file_location, 'w') as configfile:
config.write(configfile)
assert check_key_exists(file_location, section, key), "Key '{0}' failed to add to the configuration file '{1}'"\
.format(key, file_location)
def delete_section(file_location, section):
"""
Delete section from the configuration file provided
:param file_location: The file to modify
:param section: The section to delete
:assert: If the the section does exists in the file after attempting to add it
:return: None
"""
config = ConfigParser()
config.read(file_location)
config.remove_section(section)
with open(file_location, 'w') as configfile:
config.write(configfile)
assert not check_section_exists(file_location, section), \
"Section '{0}' still exists in the configuration file '{1}'".format(section, file_location)
def delete_key(file_location, section, key):
"""
Delete key from the section in the configuration file provided
:param file_location: The file to modify
:param section: The section to delete the key value
:param key: The section to delete the key value
:assert: If the the section exists in the file after attempting to delete it
:return: None
"""
logger.debug("Section exists: {0}".format(check_section_exists(file_location, section)))
assert check_section_exists(file_location, section), \
"Cannot add a key to section '{0}' since it does not exist in configuration file '{1}'".format(section, file)
config = ConfigParser()
config.read(file_location)
config.remove_option(section, key)
with open(file_location, 'w') as configfile:
config.write(configfile)
assert not check_key_exists(file_location, section, key), "Key '{0}' still exists in the configuration file '{1}'"\
.format(key, file_location)
@@ -0,0 +1,616 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Small library of functions to support autotests for asset processor
"""
# Import builtin libraries
import pytest
import binascii
import hashlib
import os
import re
import hashlib
import shutil
import logging
import subprocess
import psutil
from configparser import ConfigParser
from typing import Dict, List, Tuple, Optional, Callable
# Import LyTestTools
import ly_test_tools.environment.file_system as fs
import ly_test_tools.environment.process_utils as process_utils
from ly_test_tools.lumberyard.ap_log_parser import APLogParser
logger = logging.getLogger(__name__)
# Asset Processor fast scan system setting key/subkey
AP_FASTSCAN_KEY = r"Software\Amazon\Asset Processor\Options"
AP_FASTSCAN_SUBKEY = r"EnableZeroAnalysis"
class ProcessOutput(object):
# Process data holding object
def __init__(self) -> None:
# type() -> None
self.stdout = None
self.stderr = None
self.returncode = None
self.exception_occurred = False
def compare_assets_with_cache(assets: List[str], assets_cache_path: str) -> Tuple[List[str], List[str]]:
"""
Given a list of assets names, will try to find them (disrespecting file extensions)
from project's Cache folder with test assets
:param assets: A list of assets to be compared with Cache
:param assets_cache_path: A path to cache test assets folder
:return: A tuple with two lists - first is missing in cache assets, second is existing in cache assets
"""
missing_assets = []
existing_assets = []
if os.path.exists(assets_cache_path):
files_in_cache = list(map(fs.remove_path_and_extension, os.listdir(assets_cache_path)))
for asset in assets:
file_without_ext = fs.remove_path_and_extension(asset).lower()
if file_without_ext in files_in_cache:
existing_assets.append(file_without_ext)
files_in_cache.remove(file_without_ext)
else:
missing_assets.append(file_without_ext)
else:
missing_assets = assets
return missing_assets, existing_assets
def copy_assets_to_project(assets: List[str], source_directory: str, target_asset_dir: str) -> None:
"""
Given a list of asset names and a directory, copy those assets into the target project directory
:param assets: A list of asset names to be copied
:param source_directory: A path string where assets are located
:param target_asset_dir: A path to project tests assets directory where assets will be copied over to
:return: None
"""
if not os.path.exists(target_asset_dir):
os.mkdir(target_asset_dir)
for asset in assets:
full_name = os.path.join(source_directory, asset)
destination_fullname = os.path.join(target_asset_dir, asset)
shutil.copyfile(full_name, destination_fullname)
os.chmod(destination_fullname, 0o0777)
def prepare_test_assets(assets_path: str, function_name: str, project_test_assets_dir: str) -> str:
"""
Given function name and assets cache path, will clear cache and copy test assets assigned to function name to
project's folder
:param assets_path: Path to tests assets folder
:param function_name: Name of a function that corresponds to folder with assets
:param project_test_assets_dir: A path to project directory with test assets
:return: Returning path to copied assets folder
"""
test_assets_folder = os.path.join(assets_path, "assets", function_name)
# Some tests don't have any assets to copy, which is fine, we don't want to fail in that case
if os.path.exists(test_assets_folder):
copy_assets_to_project(os.listdir(test_assets_folder), test_assets_folder, project_test_assets_dir)
return test_assets_folder
def find_joblog_file(joblogs_path: str, regexp: str) -> str:
"""
Given path to joblogs files and asset name in form of regexp, will try to find joblog file for provided asset;
if multiple - will return first occurrence
:param joblogs_path: Path to a folder with joblogs files to look for needed file
:param regexp: Python Regexp to find the joblog file for the asset that was processed
:return: Full path to joblog file, empty string if not found
"""
for file_name in os.listdir(joblogs_path):
if re.match(regexp, file_name):
return os.path.join(joblogs_path, file_name)
return ""
def find_missing_lines_in_joblog(joblog_location: str, strings_to_verify: List[str]) -> List[str]:
"""
Given joblog file full path and list of strings to verify, will find all missing strings in the file
:param joblog_location: Full path to joblog file
:param strings_to_verify: List of string to look for in joblog file
:return: Subset of original strings list, that were not found in the file
"""
lines_not_found = []
with open(joblog_location, "r") as f:
read_data = f.read()
for line in strings_to_verify:
if line not in read_data:
lines_not_found.append(line)
return lines_not_found
def clear_project_test_assets_dir(test_assets_dir: str) -> None:
"""
On call - deletes test assets dir if it exists and creates new empty one
:param test_assets_dir: A path to tests assets dir
:return: None
"""
if os.path.exists(test_assets_dir):
fs.delete([test_assets_dir], False, True)
os.mkdir(test_assets_dir)
def get_files_hashsum(path_to_files_dir: str) -> Dict[str, int]:
"""
On call - calculates md5 hashsums for filecontents.
:param path_to_files_dir: A path to files directory
:return: Returns a dict with initial filenames from path_to_files_dir as keys and their contents hashsums as values
"""
checksum_dict = {}
try:
for fname in os.listdir(path_to_files_dir):
with open(os.path.join(path_to_files_dir, fname), "rb") as fopen:
checksum_dict[fname] = hashlib.sha256(fopen.read()).digest()
except IOError:
logger.error("An error occurred trying to read file")
return checksum_dict
def append_to_filename(file_name: str, path_to_file: str, append_text: str, ignore_extension: str) -> None:
"""
Function for appending text to file and folder names
:param file_name: Name of a file or folder
:param path_to_file: Path to file or folder
:param append_text: Text to append
:param ignore_extension: True or False for ignoring extensions
:return: None
"""
if not ignore_extension:
(name, extension) = file_name.split(".")
new_name = name + append_text + "." + extension
else:
new_name = file_name + append_text
os.rename(os.path.join(path_to_file, file_name), os.path.join(path_to_file, new_name))
def create_asset_processor_backup_directories(backup_root_directory: str, test_backup_directory: str) -> None:
"""
Function for creating the asset processor logs backup directory structure
:param backup_root_directory: The location where logs should be stored
:param test_backup_directory: The directory for the specific test being ran
:return: None
"""
if not os.path.exists(os.path.join(backup_root_directory, test_backup_directory)):
os.makedirs(os.path.join(backup_root_directory, test_backup_directory))
def backup_asset_processor_logs(bin_directory: str, backup_directory: str) -> None:
"""
Function for backing up the logs created by asset processor to designated backup directory
:param bin_directory: The bin directory created by the lumberyard build process
:param backup_directory: The location where asset processor logs should be backed up to
:return: None
"""
ap_logs = os.path.join(bin_directory, "logs")
if os.path.exists(ap_logs):
destination = os.path.join(backup_directory, "logs")
shutil.copytree(ap_logs, destination)
def platform_enabled(workspace: pytest.fixture, platform: str) -> bool:
"""
Checks to see if the platform specified is enabled for the current build of LY.
:param workspace: The current testing workspace
:param platform: The name of the platform to lookup. Example Platforms: 'Xenia', 'Provo', 'Salem'
:return: True if the platform is enabled, False if not.
"""
user_settings_file = os.path.join(workspace.paths.dev(), "_WAF_", "user_settings.options")
assert os.path.exists(user_settings_file), f"User settings file not found at {user_settings_file}"
parser = ConfigParser()
parser.read(user_settings_file)
section = platform.title() + " Options" # The Platform Options section
option = "enable_" + platform.lower()
# Make sure the platform has an options section
assert parser.has_section(section), f"Section {section} was not found in {user_settings_file}"
if parser.has_option(section, option):
entry = parser.get(section, option)
if entry.lower() == "true":
# Found 'true'
return True
elif entry.lower() == "false":
# Found 'false'
return False
else:
# Found something unexpected
# fmt:off
logger.warning(f"Found unexpected value '{entry}' in {user_settings_file} - {section}:{option}. "
f"Using default value of 'False'")
# fmt:on
else:
logger.info(f"No option '{option}' was found in the section '{section}', defaulting to 'False'")
return False
def safe_subprocess(command: str or List[str], **kwargs: Dict) -> ProcessOutput:
"""
Forwards arguments to subprocess.Popen to have a processes output
args stdout and stderr can not be passed as they are used internally
Setting check = true will change the received out put into a subprocess.CalledProcessError object
IMPORTANT: This code might fail after upgrade to python 3 due to interpretation of byte and string data.
:param command: A list of the command to execute and its arguments as split by whitespace.
:param kwargs: Keyword args forwarded to subprocess.check_output.
:return: Popen object with callable attributes that hold the piped out put of the process.
"""
cmd_string = command
if type(command) == list:
cmd_string = " ".join(command)
logger.info(f'Executing "subprocess.Popen({cmd_string})"')
# Initialize ProcessOutput object
subprocess_output = ProcessOutput()
try:
# Run process
# fmt:off
output = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, **kwargs)
# fmt:on
# Wait for process to complete
output_data = output.communicate()
# Read and process pipped outputs
subprocess_output.stderr = output_data[1]
subprocess_output.stdout = output_data[0]
# Save process return code
subprocess_output.returncode = output.returncode
except subprocess.CalledProcessError as e:
# Set object flag
subprocess_output.exception_occurred = True
# If error occurs when **kwargs includes check=True Exceptions are possible
logger.warning(f'Command "{cmd_string}" failed with returncode {e.returncode}, output:\n{e.output}')
# Read and process error outputs
subprocess_output.stderr = e.output.read().decode()
# Save error return code
subprocess_output.returncode = e.returncode
else:
logger.info(f'Successfully executed "check_output({cmd_string})"')
return subprocess_output
def processes_with_substring_in_name(substring: str) -> tuple:
"""
Finds all existing processes that contain a specified substring in their names
:param substring: the string to look for as a substring within process names
:return: a tuple of all processes containing the substring in their names or an empty tuple if none are found
"""
processes = process_utils._safe_get_processes()
targeted_processes = []
for p in processes:
try:
if substring.lower() in p.name().lower():
targeted_processes.append(p)
except psutil.NoSuchProcess as e:
logger.info(f"Process {p} was killed during processes_with_substring_in_name()!\nError: {e}")
continue
return tuple(targeted_processes)
def child_process_list(pid: int, name_filter: str = None) -> List[int]:
"""
Return the list of child process objects of the given pid
:param pid: process id of the parent process
:param name_filter: optional name to match child processes against
:return: List of matching process objects
"""
return_list = []
for child in psutil.Process(pid).children(recursive=True):
if not name_filter or child.name() == name_filter:
return_list.append(child)
return return_list
def process_cpu_usage_below(process_name: str, cpu_usage_threshold: float) -> bool:
"""
Checks whether CPU usage by a specified process is below a specified threshold
:param process_name: String to search for within the names of active processes
:param cpu_usage_threshold: Float at or above which CPU usage by a process instance is too high
:return: True if the CPU usage for each instance of the specified process is below the threshold, False if not
"""
# Get all instances of targeted process
targeted_processes = processes_with_substring_in_name(process_name)
assert len(targeted_processes) > 0, f"No instances of {process_name} were found"
# Return whether all instances of targeted process are idle
for targeted_process in targeted_processes:
logger.info(f"Process name: {targeted_process.name()}")
if hasattr(targeted_process, "pid"):
logger.info(f"Process ID: {targeted_process.pid}")
process_cpu_load = targeted_process.cpu_percent(interval=1)
logger.info(f"Process CPU load: {process_cpu_load}")
if process_cpu_load >= cpu_usage_threshold:
return False
return True
def temp_test_dir(request, dir_path: str) -> str:
"""
Creates a temporary test directory to be deleted on teardown
:param dir_path: path for the temporary test directory
:return: path to the temporary test directory
"""
# Clear the directory if it exists and create the temporary test directory
clear_project_test_assets_dir(dir_path)
# Delete the directory on teardown
request.addfinalizer(lambda: fs.delete([dir_path], False, True))
return dir_path
def get_relative_file_paths(start_dir: str, ignore_list: Optional[List[str]] = None) -> List[str]:
"""
Collects all relative paths for files under the [start_dir] directory tree.
Ignores a path if it contains any string in the [ignore_list].
"""
if ignore_list is None:
ignore_list = []
all_files = []
for root, _, files in os.walk(start_dir):
for file_name in files:
full_path = os.path.join(root, file_name)
if all([False for word in ignore_list if word in full_path]):
all_files.append(os.path.relpath(full_path, start_dir))
return all_files
def compare_lists(actual: List[str], expected: List[str]) -> bool:
"""Compares the two lists of strings. Returns false and prints any discrepancies if present."""
# Find difference between expected and actual
diff = {"actual": [], "expected": []}
for asset in actual:
if asset not in expected:
diff["actual"].append(asset)
for asset in expected:
if asset not in actual:
diff["expected"].append(asset)
# Log difference between actual and expected (if any). Easier for troubleshooting
if diff["actual"]:
logger.info("The following assets were actually found but not expected:")
for asset in diff["actual"]:
logger.info(" " + asset)
if diff["expected"]:
logger.info("The following assets were expected to be found but were actually not:")
for asset in diff["expected"]:
logger.info(" " + asset)
# True ONLY IF both diffs are empty
return not diff["actual"] and not diff["expected"]
def delete_MoveOutput_folders(search_path: List[str] or str) -> None:
"""
Deletes any directories that start with 'MoveOutput' in specified search location
:param search_path: either a single path or a list of paths to search inside for MoveOutput folders
:return: None
"""
delete_list = []
def search_one_path(search_location):
nonlocal delete_list
for file_or_folder in os.listdir(search_location):
file_or_folder_path = os.path.join(search_location, file_or_folder)
if os.path.isdir(file_or_folder_path) and file_or_folder.startswith("MoveOutput"):
delete_list.append(file_or_folder_path)
if isinstance(search_path, List):
for single_path in search_path:
search_one_path(single_path)
else:
search_one_path(search_path)
fs.delete(delete_list, False, True)
def find_queries(line: str, queries_to_find: List[str or List[str]]) -> List[str or List[str]]:
"""
Searches for strings and/or combinations of strings within a line
:param line: Line to search
:param queries_to_find: List containing strings and/or lists of strings to find
:return: List of strings and/or lists of strings found within the line
"""
queries_found = []
for query in queries_to_find:
# If query is a list then find each list item as a substring within the line
if isinstance(query, list):
subqueries_to_find = query[:]
while subqueries_to_find and subqueries_to_find[0] in line:
subqueries_to_find.pop(0)
if subqueries_to_find == []:
queries_found.append(query)
# Otherwise find query as a substring within the line
elif query in line:
queries_found.append(query)
return queries_found
def validate_log_output(
log_output: List[str or List[str]],
expected_queries: List[str or List[str]] = [],
unexpected_queries: List[str or List[str]] = [],
failure_cb: Callable = None
) -> None:
"""
Asserts that the log output contains all expected queries and no unexpected queries.
:param log_output: log output of the application
:param expected_queries: String or list containing strings and/or lists of strings to be found
:param unexpected_queries: String or list containing strings and/or lists of strings not to be found
:param failure_cb: Optional callback when log output isn't expected, useful for printing debug info
:return: None
"""
expected_queries = [expected_queries] if isinstance(expected_queries, str) else expected_queries
unexpected_queries = [unexpected_queries] if isinstance(unexpected_queries, str) else unexpected_queries
unexpectedly_found = []
for line in log_output:
# Remove queries expectedly found in the log from queries to expect
for found_query in find_queries(line, expected_queries):
expected_queries.remove(found_query)
# Save unexpectedly found lines
if find_queries(line, unexpected_queries):
unexpectedly_found.append(line)
if failure_cb and (len(unexpected_queries) > 0 or len(expected_queries) > 0):
failure_cb()
# Assert no unexpected lines found and all expected queries found
assert unexpectedly_found == [], f"Unexpected line(s) were found in the log run: {unexpectedly_found}"
assert expected_queries == [], f"Expected query(s) were not found in the log run: {expected_queries}"
def validate_log_messages(
log_file: str,
expected_queries: str or List[str or List[str]] = [],
unexpected_queries: str or List[str or List[str]] = [],
) -> None:
"""
Asserts that the most recent log run contains all expected queries and no unexpected queries. Queries can be strings
and/or combinations of strings [[using nested lists]].
:param log_file: Path to the log file being used
:param expected_queries: String or list containing strings and/or lists of strings to be found
:param unexpected_queries: String or list containing strings and/or lists of strings not to be found
:return: None
"""
# Search the log lines in the latest log run
validate_log_output(APLogParser(log_file).runs[-1]["Lines"], expected_queries, unexpected_queries)
def validate_relocation_report(
log_file: str,
expected_queries: str or List[str or List[str]] = [],
unexpected_queries: str or List[str or List[str]] = [],
) -> None:
"""
Asserts that the relocation report section of the most recent log run contains all expected queries and no
unexpected queries. Queries can be strings and/or combinations of strings [[using nested lists]].
:param log_file: Path to the log file being used
:param expected_queries: String or list containing strings and/or lists of strings to be found
:param unexpected_queries: String or list containing strings and/or lists of strings not to be found
:return: None
"""
expected_queries = [expected_queries] if isinstance(expected_queries, str) else expected_queries
unexpected_queries = [unexpected_queries] if isinstance(unexpected_queries, str) else unexpected_queries
unexpectedly_found = []
in_relocation_report = False
# Search the log lines which appear between opening and closing RELOCATION REPORT lines in the latest log run
for line in APLogParser(log_file).runs[-1]["Lines"]:
if "RELOCATION REPORT" in line:
in_relocation_report = not in_relocation_report
continue # Go to next log line
if in_relocation_report:
# Remove queries expectedly found in the relocation report from queries to expect
for found_query in find_queries(line, expected_queries):
expected_queries.remove(found_query)
# Save unexpectedly found lines
if find_queries(line, unexpected_queries):
unexpectedly_found.append(line)
# Assert no unexpected lines found and all expected queries found
assert unexpectedly_found == [], f"Unexpected line(s) were found in the relocation report: {unexpectedly_found}"
assert expected_queries == [], f"Expected query(s) were not found in the relocation report: {expected_queries}"
def get_paths_from_wildcard(root_path: str, wildcard_str: str) -> List[str]:
"""
Convert a wildcard path into a list of existing full paths.
:param root_path: Full path in which to search for matches for the wildcard string
:param wildcard_str: Must contain exactly one "*", at the beginning or end, or be simply "*"
:return: List of all full paths which satisfy the wildcard criteria
"""
rel_path_list = get_relative_file_paths(root_path)
if not wildcard_str == "*":
if wildcard_str.startswith("*"):
rel_path_list = [item for item in rel_path_list if item.endswith(wildcard_str[1:])]
elif wildcard_str.endswith("*"):
rel_path_list = [item for item in rel_path_list if item.startswith(wildcard_str[0:-1])]
return [os.path.join(root_path, item) for item in rel_path_list]
def check_for_perforce():
command_list = ['p4', 'info']
try:
p4_output = subprocess.check_output(command_list).decode('utf-8')
except subprocess.CalledProcessError as e:
logger.error(f"Failed to call {command_list} with error {e}")
return False
if not p4_output.startswith("User name:"):
logger.warning(f"Perforce not found, output was {p4_output}")
return False
client_root_match = re.search(r"Client root: (.*)\r", p4_output)
if client_root_match is None:
logger.warning(f"Could not determine client root for p4 workspace. Perforce output was {p4_output}")
return False
else:
# This requires the tests to be in the Perforce path that the tests run against.
working_path = os.path.realpath(__file__).replace("\\", "/").lower()
client_root = client_root_match.group(1).replace("\\", "/").lower()
if not working_path.startswith(client_root):
logger.error(f"""Perforce client root '{client_root}' does not contain current test directory '{working_path}'.
Please run this test with a Perforce workspace that contains the test asset directory path.""")
return False
logger.info(f"Perforce found, output was {p4_output}")
return True
def get_file_hash(filePath, hashBufferSize = 65536):
assert os.path.exists(filePath), f"Cannot get file hash, file at path '{filePath}' does not exist."
sha1 = hashlib.sha1()
with open(filePath, 'rb') as cacheFile:
while True:
data = cacheFile.read(hashBufferSize)
if not data:
break
sha1.update(data)
return sha1.hexdigest()
@@ -0,0 +1,176 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
LySettings provides an API for modifying settings files and creating/restoring backups of them.
"""
import fileinput
import logging
import re
import os
import ly_test_tools.environment.file_system
logger = logging.getLogger(__name__)
class LySettings(object):
"""
LySettings provides an API for modifying settings files and creating/restoring backups of them.
"""
def __init__(self, temp_path, resource_locator):
self._temp_path = temp_path
self._resource_locator = resource_locator
def get_temp_path(self):
return self._temp_path
def modify_asset_processor_setting(self, setting, value):
logger.info(f'Updating setting {setting} to {value}')
_edit_text_settings_file(self._resource_locator.asset_processor_config_file(), setting, value)
def modify_platform_setting(self, setting, value):
logger.info(f'Updating setting {setting} to {value}')
_edit_text_settings_file(self._resource_locator.platform_config_file(), setting, value)
def modify_bootstrap_setting(self, setting, value, bootstrap_path=None):
logger.info(f'Updating setting {setting} to {value}')
_edit_text_settings_file(bootstrap_path or self._resource_locator.bootstrap_config_file(), setting, value)
def modify_shader_compiler_setting(self, setting, value):
logger.info(f'Updating setting {setting} to {value}')
_edit_text_settings_file(self._resource_locator.shader_compiler_config_file(), setting, value)
def backup_asset_processor_settings(self, backup_path=None):
self._backup_settings(self._resource_locator.asset_processor_config_file(), backup_path)
def backup_platform_settings(self, backup_path=None):
"""
Creates a backup of the platform settings file (~/dev/system_[platform].cfg) in the backup_path. If no path is
provided, it will store in the workspace temp path (the contents of the workspace temp directory are removed
during workspace teardown)
"""
self._backup_settings(self._resource_locator.platform_config_file(), backup_path)
def backup_bootstrap_settings(self, backup_path=None):
"""
Creates a backup of the bootstrap settings file (~/dev/bootstrap.cfg) in the backup_path. If no path is
provided, it will store in the workspace temp path (the contents of the workspace temp directory are removed
during workspace teardown)
"""
self._backup_settings(self._resource_locator.bootstrap_config_file(), backup_path)
def backup_shader_compiler_settings(self, backup_path=None):
self._backup_settings(self._resource_locator.shader_compiler_config_file(), backup_path)
def restore_asset_processor_settings(self, backup_path):
self._restore_settings(self._resource_locator.asset_processor_config_file(), backup_path)
def restore_platform_settings(self, backup_path=None):
"""
Restores the platform settings file (~/dev/system_[platform].cfg) from its backup.
The backup is stored in the backup_path.
If no backup_path is provided, it will attempt to retrieve the backup from the workspace temp path.
"""
self._restore_settings(self._resource_locator.platform_config_file(), backup_path)
def restore_bootstrap_settings(self, backup_path=None):
"""
Restores the bootstrap settings file (~/dev/bootstrap.cfg) from its backup.
The backup is stored in the backup_path.
If no backup_path is provided, it will attempt to retrieve the backup from the workspace temp path.
"""
self._restore_settings(self._resource_locator.bootstrap_config_file(), backup_path)
def restore_shader_compiler_settings(self, backup_path=None):
self._restore_settings(self._resource_locator.shader_compiler_config_file(), backup_path)
def _backup_settings(self, settings_file, backup_path):
"""
Creates a backup of the settings file in the backup_path. If no path is
provided, it will store in the workspace temp path (the contents of the workspace temp directory are removed
during workspace teardown)
"""
if not backup_path:
backup_path = self._temp_path
ly_test_tools.environment.file_system.create_backup(settings_file, backup_path)
def _restore_settings(self, settings_file, backup_path):
"""
Restores the settings file from its backup stored in backup_path. If no path is provided, it will attempt
to retrieve the backup from the workspace temp path.
"""
if not backup_path:
backup_path = self._temp_path
ly_test_tools.environment.file_system.restore_backup(settings_file, backup_path)
def _edit_text_settings_file(settings_file, setting, value, comment_char=""):
"""
Find and set a specific setting in a text based settings file. Uses "setting = value" syntax to identify setting,
ignoring whitespace. Will append "setting = value" to a text file if it can not find the setting already.
Note all found instances of the setting key in the file will be changed.
Unintentional setting changes may happen for files with multiple settings named the same
Using the comment_char, users can set a value on a corresponding setting but leave it commented out
--setting=value
:param settings_file: The path to a settings file
:param setting: The target key setting to update
:param value: The new key value
:param comment_char: A character identifier for commenting out data in a settings file
"""
if not os.path.isfile(settings_file):
raise IOError(f"Invalid file and/or path {settings_file}.")
match_obj = None
document = None
try:
# fileinput can be very destructive when used in conjunction with "with as" due to temp file creation.
# Allows using print to rewrite lines.
logger.debug(f"Opening {settings_file}")
document = fileinput.input(settings_file, inplace=True)
for line in document:
# Remove whitespace to avoid double spacing the output.
line = line.rstrip()
if setting in line:
# Run regex on each line, and make the change if we have an exact match.
setting_regex = re.compile("([-;]+)?(.*)=(.*)")
possible_match = setting_regex.match(line)
if possible_match is None or setting != possible_match.group(2).strip():
print(line)
continue
match_obj = possible_match
logger.debug(f"Found {setting} in {settings_file}")
# Print the new value for the setting.
if value == "":
print("")
else:
print(f"{comment_char}{setting}={value}")
logger.info(f"Updated setting {setting} in {settings_file} to {value} from {match_obj.group(3)}")
else:
print(line)
except PermissionError as error:
logger.warning(f"PermissionError, possibly due to ({settings_file}) already being open. Error: {error}")
finally:
if document is not None:
document.close()
# Append the settings change if setting doesn't exist in file.
if match_obj is None:
logger.info(
"Unable to locate setting in file. "
f"Appending {comment_char}{setting}={value} to {settings_file}.")
with open(settings_file, "a") as document:
document.write(f"{comment_char}{setting}={value}")
@@ -0,0 +1,74 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
A class to control functionality of Lumberyard's shader compiler.
The class manages a shader compiler process in a workspace.
"""
import logging
import subprocess
import ly_test_tools.environment.waiter as waiter
import ly_test_tools.environment.process_utils as process_utils
from ly_test_tools import MAC
logger = logging.getLogger(__name__)
class ShaderCompiler():
def __init__(self, workspace):
# type: (AbstractWorkspaceManager) -> ShaderCompiler
"""
Takes in an WorkspaceManager to set the path from the ResourceLocator
:param workspace: The workspace to use to locate path to shader compiler executable
"""
self._workspace = workspace
self._sc_proc = None
def start(self):
"""
Starts the shader compiler and stores the process in self._sc_proc.
:return: None
"""
if self._sc_proc is not None:
logger.info(
f'Attempted to start shader compiler at the path: {self._workspace.paths.get_shader_compiler_path()}, '
'but we already have one open!')
return
if MAC:
raise NotImplementedError('Mac shader compiler not implemented.')
logger.info(f"Build::setup_start_shadercompiler -- {self._workspace.paths.get_shader_compiler_path()}")
# Running with basic user permissions since the shader compile server
# warns against running as admin
self._sc_proc = subprocess.Popen([
'RunAs',
'/trustlevel:0x20000',
self._workspace.paths.get_shader_compiler_path(),
])
def stop(self):
"""
Stops the shader compiler stored in _sc_proc.
:return: None
"""
if self._sc_proc is None:
logger.info(
f'Attempted to stop shader compiler at the path: {self._workspace.paths.get_shader_compiler_path()}, '
'but we do not have any open!')
return
logger.info('Killing shader compiler process.')
process_utils.kill_processes_started_from(self._workspace.paths.get_shader_compiler_path())
waiter.wait_for(lambda: self._sc_proc.poll() is not None)
self._sc_proc = None
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,252 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Utilities for interacting with Android devices.
"""
import datetime
import logging
import os
import psutil
import subprocess
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.environment.waiter as waiter
logger = logging.getLogger(__name__)
SINGLE_DEVICE = 0
MULTIPLE_DEVICES = 1
NO_DEVICES = 2
def can_run_android():
"""
Determine if android can be run by trying to use adb.
:return: True if the adb command returns success and False otherwise.
"""
try:
with open(os.devnull, 'wb') as DEVNULL:
return_code = process_utils.safe_check_call(["adb", "version"], stdout=DEVNULL, stderr=subprocess.STDOUT)
if return_code == 0:
return True
except Exception: # purposefully broad
logger.info("Android not enabled")
logger.debug("Attempt to verify adb installation failed", exc_info=True)
return False
def check_adb_connection_state():
"""
Wrapper for gathering output of adb get-state command.
:return: The output of the adb command as an int, raises RunTimeError otherwise.
"""
with psutil.Popen('adb get-state', stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as proc:
output = proc.communicate()[0].decode('utf-8')
if 'more than one device' in output:
return MULTIPLE_DEVICES
elif 'no devices/emulators found' in output:
return NO_DEVICES
elif 'device' == output.strip():
return SINGLE_DEVICE
else:
raise RuntimeError("Detected unhandled output from adb get-state: {}".format(output.strip()))
def reverse_tcp(device, host_port, device_port):
"""
Tunnels a TCP port over USB from the device to the local host.
:param device: Device id of a connected device
:param host_port: Port to reverse to
:param device_port: Port to reverse from
:return: None
"""
logger.debug('Running ADB reverse command')
cmd = ['adb', '-s', device, 'reverse', f'tcp:{host_port}', f'tcp:{device_port}']
logger.debug(f'Executing command: "{cmd}"')
process_utils.check_output(cmd)
def forward_tcp(device, host_port, device_port):
"""
Tunnels a TCP port over USB from the local host to the device.
:param device: Device id of a connected device
:param host_port: Port to forward from
:param device_port: Port to forward to
:return: None
"""
logger.debug('Running ADB forward command')
cmd = ['adb', '-s', device, 'forward', 'tcp:{}'.format(host_port), 'tcp:{}'.format(device_port)]
process_utils.check_output(cmd)
logger.debug('Executing command: %s' % cmd)
def undo_tcp_port_changes(device):
"""
Undoes all 'adb forward' and 'adb reverse' commands for forwarding and reversing TCP ports.
:param device: Device id of a connected device
:return: None
"""
logger.debug('Reverting "adb forward" and "adb reverse" commands.')
undo_tcp_forward = ['adb', '-s', device, 'forward', '--remove-all']
undo_tcp_reverse = ['adb', '-s', device, 'reverse', '--remove-all']
process_utils.check_output(undo_tcp_forward)
process_utils.check_output(undo_tcp_reverse)
logger.debug('Reverted forwarded/reversed TCP ports using commands: {} && {}'.format(
undo_tcp_forward, undo_tcp_reverse))
def get_screenshots(device, package_name, project):
"""
Captures a Screenshot for the game and stores it in the project folder on the Devices.
:param device: Device id of a connected device
:param package_name: Name of the Android package
:param project: Name of the lumberyard project
:return: None
"""
screenshot_cmd = ['adb',
'-s',
device,
'shell',
'screencap',
'-p',
'/sdcard/Android/data/{}/files/log/{}-{}.png'.format(package_name, project, device)]
process_utils.check_output(screenshot_cmd)
logger.debug('Screenshot Command Ran: {}'.format(screenshot_cmd))
def pull_files_to_pc(package_name, logs_path, device=None):
"""
Pulls a file from the package installed on the device to the PC.
:param device: ID of a connected Android device
:param package_name: Name of the Android package
:param logs_path: Path to the logs location on the local machine
:return: None
"""
directory = os.path.join(logs_path, device)
if not os.path.exists(directory):
os.makedirs(directory)
pull_cmd = ['adb']
if device is not None:
pull_cmd.extend(['-s', device])
pull_cmd.extend(['pull', '/sdcard/Android/data/{}/files/log/'.format(package_name), directory])
try:
process_utils.check_output(pull_cmd, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as err:
if 'does not exist' in err.output:
logger.info('Could not pull logs since none exist on device {}'.format(device))
else:
raise
logger.debug('Pull File Command Ran successfully: {}'.format(str(pull_cmd)))
def push_files_to_device(source, destination, device=None):
"""
Pushes a file to a specific location. Source being from the PC and the destination is the Android destination
Params.
:param source: The file location on the host machine
:param destination: The destination on the Android device we want to push the files
:param device: The device ID of the device to push files to
:return: None
"""
logger.debug('Pushing files from windows location {} to device {} location {}'.format(source, device, destination))
cmd = ['adb']
if device is not None:
cmd.extend(["-s", device])
cmd.extend(["push", source, destination])
push_result = process_utils.check_output(cmd)
logger.debug('Push File Command Ran: {}'.format(str(cmd)))
if 'pushed' not in push_result:
raise RuntimeError('[AndroidLauncher] Failed to push file to device: {}!'.format(device))
def start_adb_server():
"""
Starts the ADB server.
:return: None
"""
logger.debug('Starting adb server')
cmd = 'adb start-server'
process_utils.check_call(cmd)
def kill_adb_server():
"""
Kills the ADB server.
:return: None
"""
logger.debug('Killing adb server')
cmd = 'adb kill-server'
process_utils.check_call(cmd)
def wait_for_android_device_load(android_device_id, timeout=60):
"""
Utilizes adb logcat commands to make sure the device is fully loaded before connecting the RemoteConsole()
Helps deal with race conditions that may occur when calls are made to the LY client before loading is complete.
:param android_device_id: string ID for the Android device to target.
:param timeout: int seconds to wait until raising an exception.
:return: output from the command if it succeeds, raises an exception otherwise.
"""
adb_prefix = ['adb', '-s', android_device_id]
current_time = datetime.datetime.now().strftime('%m-%d %H:%M:%S.%f') # Example output: '01-28 16:56:28.271000'
wait_command = []
wait_command.extend(adb_prefix)
wait_command.extend(['logcat',
'-e',
'\\bFinished loading textures\\b', # exact regex match for 'Finished loading textures'
'-t',
current_time])
try:
waiter.wait_for(
lambda: process_utils.check_output(wait_command),
timeout=timeout,
exc=subprocess.CalledProcessError)
except subprocess.CalledProcessError:
logger.exception("Android device with ID: {} never finished loading".format(android_device_id))
def get_devices():
"""
Utilizes the 'adb devices' command to check that a device is connected to the host machine.
:return: A list of connected device IDs or an empty list if none are found.
"""
devices_list = []
cmd = 'adb devices'
# Example cmd_output: 'List of devices attached\r\nemulator-5554\tdevice\r\nA1B2C3D4E5\tdevice\r\n\r\n'
cmd_output = process_utils.check_output(cmd)
# Example raw_devices_output: ['List of devices attached', 'emulator-5554\tdevice', 'A1B2C3D4E5\tdevice']
raw_devices_output = cmd_output.strip().splitlines()
for raw_output in raw_devices_output:
updated_raw_output = raw_output.split('\t') # ['emulator-5554', 'device'] or ['List of devices attached']
if len(updated_raw_output) > 1:
devices_list.append(updated_raw_output[0])
return devices_list # Example devices_list: ['emulator-5554', 'A1B2C3D4E5']
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,102 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Helpers for RAD Telemetry, currently only for Windows
"""
import logging
import subprocess
import os
import ly_test_tools.environment.process_utils as process_utils
from ly_test_tools import WINDOWS
_RAD_DEFAULT_PORT = 4719
_CREATE_NEW_PROCESS_GROUP = 0x00000200
_DETACHED_PROCESS = 0x00000008
_WINDOWS_FLAGS = _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS
RAD_TOOLS_SUBPATH = os.path.join("dev", "Gems", "RADTelemetry", "Tools")
log = logging.getLogger(__name__)
def __set_firewall_rule(direction, port):
"""
Adds a Windows firewall rule if one does not yet exist. Requires administrator privilege.
:param direction: Must be 'in' or 'out'
:param port: target port to open
:return: None
"""
assert WINDOWS, "Only implemented for Windows platforms"
log.info(f"Setting firewall rule on port '{port}' for direction '{direction}'")
show_rule = ['netsh', 'advfirewall', 'firewall', 'show', 'rule', 'name=RADTelemetry', f'dir={direction}']
show_result = process_utils.safe_check_call(show_rule)
if show_result == 0:
log.debug("Rule already exists")
else:
add_rule = ['netsh', 'advfirewall', 'firewall', 'add', 'rule', 'name=RADTelemetry', f'dir={direction}',
'action=allow', 'protocol=TCP', f'localport={port}']
process_utils.check_call(add_rule)
log.debug("Added new rule")
def set_firewall_rules():
"""
Opens firewall ports necessary for a remote device to communicate with the RAD Telemetry server.
Requires administrator privilege.
:return: None
"""
assert WINDOWS, "Only implemented for Windows platforms"
__set_firewall_rule(direction="in", port=_RAD_DEFAULT_PORT)
__set_firewall_rule(direction="out", port=_RAD_DEFAULT_PORT)
def launch_server(dev_path):
"""
Launches the RAD Telemetry server to collect telemetry captures.
:param dev_path: path to the folder containing engineroot.txt
:return: None
"""
assert WINDOWS, "Only implemented for Windows platforms"
server_path = os.path.join(dev_path, RAD_TOOLS_SUBPATH, "tm_server.exe")
subprocess.Popen([server_path], creationflags=_WINDOWS_FLAGS, close_fds=True)
log.info(f"Launched RAD Server from {server_path}")
def terminate_servers(dev_path):
"""
Terminate the RAD Telemetry server and all related tools, important before collecting any of its captures
:param dev_path: path to the folder containing engineroot.txt
:return: None
"""
assert WINDOWS, "Only implemented for Windows platforms"
rad_path = os.path.join(dev_path, RAD_TOOLS_SUBPATH)
process_utils.kill_processes_started_from(rad_path)
def get_capture_path(dev_path):
"""
Returns the path of the tm_server.exe file
:return: path to the folder containing output for local servers
"""
assert WINDOWS, "Only implemented for Windows platforms"
get_folder_path = os.path.join(dev_path, RAD_TOOLS_SUBPATH, "tm_server.exe")
output = process_utils.check_output([get_folder_path])
return output.strip()
+57
View File
@@ -0,0 +1,57 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import os
import platform
from setuptools import setup, find_packages
from setuptools.command.develop import develop
from setuptools.command.build_py import build_py
PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
PYTHON_64 = platform.architecture()[0] == '64bit'
if __name__ == '__main__':
if not PYTHON_64:
raise RuntimeError("32-bit Python is not a supported platform.")
with open(os.path.join(PROJECT_ROOT, 'README.txt')) as f:
long_description = f.read()
setup(
name="ly_test_tools",
version="1.0.0",
description='Lumberyard Python Test Tools',
long_description=long_description,
packages=find_packages(where='Tools', exclude=['tests']),
install_requires=[
'imageio',
'numpy',
'pluggy',
'psutil',
'pyscreenshot',
'pytest',
'pytest-mock',
'pytest-timeout',
'six',
'scipy',
],
tests_require=[
],
entry_points={
'pytest11': [
'ly_test_tools=ly_test_tools._internal.pytest_plugin.test_tools_fixtures',
'testrail_filter=ly_test_tools._internal.pytest_plugin.case_id',
'terminal_report=ly_test_tools._internal.pytest_plugin.terminal_report'
],
},
)
+68
View File
@@ -0,0 +1,68 @@
#
# All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
# its licensors.
#
# For complete copyright and license terms please see the LICENSE at the root of this
# distribution (the "License"). All use of this software is governed by the License,
# or, if provided, by the license below or the license accompanying this file. Do not
# remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#
# LyTestTools tests.
#
# Unit tests.
ly_add_pytest(
NAME LyTestTools_UnitTests
TEST_SUITE smoke
PATH ${CMAKE_CURRENT_LIST_DIR}/unit/
)
if(PAL_TRAIT_BUILD_HOST_TOOLS AND PAL_TRAIT_BUILD_TESTS_SUPPORTED AND AutomatedTesting IN_LIST LY_PROJECTS)
# Integration tests.
ly_add_pytest(
NAME LyTestTools_IntegTests_Sanity_smoke_no_gpu
PATH ${CMAKE_CURRENT_LIST_DIR}/integ/sanity_tests.py
TEST_SERIAL
TEST_SUITE smoke
RUNTIME_DEPENDENCIES
Legacy::Editor
AssetProcessor
AutomatedTesting.GameLauncher
AutomatedTesting.Assets
Legacy::CryRenderNULL
)
ly_add_pytest(
NAME LyTestTools_IntegTests_ProcessUtils_smoke_no_gpu
PATH ${CMAKE_CURRENT_LIST_DIR}/integ/test_process_utils.py
TEST_SERIAL
TEST_SUITE smoke
)
ly_add_pytest(
NAME LyTestTools_IntegTests_Settings_smoke_no_gpu
PATH ${CMAKE_CURRENT_LIST_DIR}/integ/test_settings.py
TEST_SERIAL
TEST_SUITE smoke
RUNTIME_DEPENDENCIES
AssetProcessor
AutomatedTesting.GameLauncher
AutomatedTesting.Assets
Legacy::CryRenderNULL
)
# Regression tests.
ly_add_pytest(
NAME LyTestTools_IntegTests_RegressionTests_periodic_no_gpu
PATH ${CMAKE_CURRENT_LIST_DIR}/integ/test_regression.py
TEST_SERIAL
TEST_SUITE periodic
RUNTIME_DEPENDENCIES
Legacy::Editor
AssetProcessor
AutomatedTesting.GameLauncher
AutomatedTesting.Assets
Legacy::CryRenderNULL
)
endif()
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,136 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Example test using LyTestTools to test Lumberyard.
"""
# Python built-in dependencies.
import logging
# Third party dependencies.
import pytest
# ly_test_tools dependencies.
import ly_test_tools.log.log_monitor
import ly_remote_console.remote_console_commands as remote_console_commands
# Configuring the logging is done in ly_test_tools at the following location:
# ~/dev/Tools/LyTestTools/ly_test_tools/_internal/log/py_logging_util.py
# Use the following logging pattern to hook all test logging together:
logger = logging.getLogger(__name__)
@pytest.fixture
def remote_console(request):
"""
Creates a RemoteConsole() class instance to send console commands to the
Lumberyard client console.
:param request: _pytest.fixtures.SubRequest class that handles getting
a pytest fixture from a pytest function/fixture.
:return: ly_remote_console.remote_console_commands.RemoteConsole class instance
representing the Lumberyard remote console executable.
"""
# Initialize the RemoteConsole object to send commands to the Lumberyard client console.
console = remote_console_commands.RemoteConsole()
# Custom teardown method for this remote_console fixture.
def teardown():
console.stop()
# Utilize request.addfinalizer() to add custom teardown() methods.
request.addfinalizer(teardown) # This pattern must be used in pytest version
return console
# Shared parameters & fixtures for all test methods inside the TestSystemExample class.
@pytest.mark.usefixtures("automatic_process_killer")
@pytest.mark.parametrize('project', ['SamplesProject'])
class TestSystemExample(object):
"""
Example test case class to hold a set of test case methods.
The amount of tests run is based on the parametrization stacking made in each test method or class.
For this test, we placed unique test values in test methods and shared test values in the test class.
We also assume building has already been done, but the test should error if the build is mis-configured.
"""
# This test method needs specific parameters not shared by all other tests in the class.
# For targeting specific launchers, use the 'launcher_platform' pytest param like below:
# @pytest.mark.parametrize("launcher_platform", ['android'])
# If you want to target different AssetProcessor platforms, use asset_processor_platform:
# @pytest.mark.parametrize("asset_processor_platform", ['android'])
@pytest.mark.parametrize('level', ['simple_jacklocomotion'])
@pytest.mark.parametrize('load_wait', [120])
@pytest.mark.test_case_id('C16806863')
def test_SystemTestExample_AllSupportedPlatforms_LaunchSamplesProject(
# launcher_platform, asset_processor_platform, # Re-add these here if you plan to use them.
self, launcher, remote_console, level, load_wait):
"""
Tests launching the SamplesProject then launches the Lumberyard client &
loads the "simple_jacklocomotion" level using the remote console.
Assumes the user already setup & built their machine for the test.
"""
# Launch the Lumberyard client & remote console test case:
with launcher.start():
remote_console.start()
launcher_load = remote_console.expect_log_line(
match_string='========================== '
'Finished loading textures '
'============================',
timeout=load_wait)
# Assert loading was successful using remote console logs:
assert launcher_load, (
'Launcher failed to load Lumberyard client with the '
f'"{level}" level - waited "{load_wait}" seconds.')
# This test method only needs pytest.mark report values and shared test class parameters.
@pytest.mark.parametrize('processes_to_kill', ['Editor.exe'])
@pytest.mark.parametrize("launcher_platform", ['windows_editor'])
@pytest.mark.test_case_id('C16806864')
def test_SystemTestExample_AllSupportedPlatforms_LaunchEditor(self, editor, processes_to_kill, launcher_platform):
"""
Tests launching the Lumberyard Editor is successful with the current build.
"""
# Launch the Lumberyard editor & verify load is successful:
with editor.start():
assert editor.is_alive(), (
'Editor failed to launch for the current Lumberyard build.')
# Log monitoring example test.
@pytest.mark.parametrize('level', ['simple_jacklocomotion'])
@pytest.mark.parametrize('expected_lines', [['Log Monitoring test 1', 'Log Monitoring test 2']])
@pytest.mark.parametrize('unexpected_lines', [['Unexpected test 1', 'Unexpected test 2']])
@pytest.mark.test_case_id('C21202585')
def test_SystemTestExample_AllSupportedPlatforms_LogMonitoring(self, level, launcher, expected_lines,
unexpected_lines):
"""
Tests that the logging paths created by LyTestTools can be monitored for results using the log monitor.
"""
# Launch the Lumberyard client & initialize the log monitor.
file_to_monitor = launcher.workspace.info_log_path
log_monitor = ly_test_tools.log.log_monitor.LogMonitor(launcher=launcher,
log_file_path=file_to_monitor)
# Generate log lines to the info log using logger.
for expected_line in expected_lines:
logger.info(expected_line)
# Start the Lumberyard client & test that the lines we logged can be viewed by the log monitor.
with launcher.start():
log_test = log_monitor.monitor_log_for_lines(
expected_lines=expected_lines, # Defaults to None.
unexpected_lines=unexpected_lines, # Defaults to None.
halt_on_unexpected=True, # Defaults to False.
timeout=60) # Defaults to 30
# Assert the log monitor detected expected lines and did not detect any unexpected lines.
assert log_test, (
f'Log monitoring failed. Used expected_lines values: {expected_lines} & '
f'unexpected_lines values: {unexpected_lines}')
+10
View File
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,59 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
A sanity test for the built-in fixtures.
Launch the windows launcher attached to the currently installed instance.
"""
import logging
import pytest
import ly_test_tools
import ly_test_tools.launchers.launcher_helper as launcher_helper
import ly_test_tools.builtin.helpers as helpers
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.environment.waiter as waiter
pytestmark = pytest.mark.SUITE_smoke
logger = logging.getLogger(__name__)
# Note: For device testing, device ids must exist in ~/ly_test_tools/devices.ini, see README.txt for more info.
@pytest.mark.parametrize("project", ["AutomatedTesting"])
class TestAutomatedTestingProject(object):
def test_StartGameLauncher_Sanity(self, project):
process_utils.kill_processes_named(names=process_utils.LY_PROCESS_KILL_LIST, ignore_extensions=True)
try:
workspace = helpers.create_builtin_workspace(project=project)
launcher = launcher_helper.create_launcher(workspace)
launcher.args.extend(['-NullRenderer', '-BatchMode'])
with launcher.start():
waiter.wait_for(lambda: process_utils.process_exists(f"{project}.GameLauncher.exe", ignore_extensions=True))
finally:
process_utils.kill_processes_named(names=process_utils.LY_PROCESS_KILL_LIST, ignore_extensions=True)
@pytest.mark.skipif(not ly_test_tools.WINDOWS, reason="Editor currently only functions on Windows")
def test_StartEditor_Sanity(self, project):
process_utils.kill_processes_named(names=process_utils.LY_PROCESS_KILL_LIST, ignore_extensions=True)
try:
workspace = helpers.create_builtin_workspace(project=project)
editor = launcher_helper.create_editor(workspace)
editor.args.extend(['-NullRenderer', '-autotest_mode'])
with editor.start():
waiter.wait_for(lambda: process_utils.process_exists("Editor", ignore_extensions=True))
finally:
process_utils.kill_processes_named(names=process_utils.LY_PROCESS_KILL_LIST, ignore_extensions=True)
@@ -0,0 +1,47 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import os
import subprocess
import pytest
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.environment.waiter as waiter
from ly_test_tools import WINDOWS
if WINDOWS:
pytestmark = pytest.mark.SUITE_smoke
else:
pytestmark = pytest.mark.skipif(not WINDOWS, reason="Only runs on Windows")
class TestSubprocessCheckOutputWrapper(object):
def test_KillWindowsProgram_WindowsProgramStarted_KilledSuccessfully(self):
windows_program = 'timeout.exe'
windows_directory = os.environ.get('windir')
command = [
os.path.join(f'{windows_directory}', 'System32', f'{windows_program}'),
'/T', # Timeout flag
'4', # 4 seconds
]
def process_killed():
return not process_utils.process_exists(windows_program, ignore_extensions=True)
assert os.path.exists(command[0]), (
f'The {windows_program} executable does not exist at: {command[0]}'
)
with subprocess.Popen(command, creationflags=subprocess.CREATE_NEW_CONSOLE) as process:
if process_utils.process_exists(windows_program, ignore_extensions=True):
process_utils.kill_process_with_pid(process.pid)
waiter.wait_for(process_killed, timeout=2) # Raises exception if the process is alive.
@@ -0,0 +1,75 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Regression tests for the built-in fixtures.
"""
import logging
import os
import pytest
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.environment.file_system as file_system
import ly_test_tools.environment.waiter as waiter
from ly_test_tools import WINDOWS
pytestmark = pytest.mark.SUITE_periodic
logger = logging.getLogger(__name__)
@pytest.fixture(scope="function")
def editor_closed_checker(request):
"""
Verifies that the Editor and AP processes have been terminated when the test ends.
"""
test_name = request.node
yield
# The Editor fixture should've terminated the Editor and the AP processes
processes = ['Editor', 'AssetProcessor']
processes_found = []
for process in processes:
if process_utils.process_exists(process, True):
processes_found.append(f"Process '{process}' should have been terminated by the fixture after the test"
f" {test_name} finished.")
process_utils.kill_processes_named(process, True)
assert not processes_found, f"Editor fixture unexpectedly did not clean up open processes, processes still open: {processes_found}"
@pytest.fixture(scope="function")
def log_cleaner(workspace):
"""
Removes Game and Editor logs before test execution
"""
logs = ['Game.log', 'Editor.log']
for log in logs:
log_file = os.path.join(workspace.paths.project_log(), log)
if os.path.exists(log_file):
file_system.delete([log_file], True, False)
@pytest.mark.usefixtures("automatic_process_killer")
@pytest.mark.usefixtures("log_cleaner")
@pytest.mark.parametrize("project", ["AutomatedTesting"])
@pytest.mark.skipif(not WINDOWS, reason="Editor currently only functions on Windows")
@pytest.mark.parametrize("launcher_platform", ['windows_editor'])
class TestEditorFixture(object):
def test_EditorNotClosed_FixtureStopsProcesses(self, editor_closed_checker, editor, launcher_platform):
# Set autotest mode and disable GPU usage
editor.args.extend(['-NullRenderer', '-autotest_mode'])
log_file = os.path.join(editor.workspace.paths.project_log(), "Editor.log")
editor.start()
waiter.wait_for(lambda: os.path.exists(log_file), timeout=180) # time out increased due to bug SPEC-3175
assert editor.is_alive()
# This test doesn't call editor.stop() explicitly. Instead it uses the editor_closed_checker fixture to verify
# that the editor fixture closes the editor and AP processes.
@@ -0,0 +1,52 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import filecmp
import os
import pytest
pytestmark = pytest.mark.SUITE_smoke
class TestLySettings(object):
@pytest.mark.parametrize("project", ["AutomatedTesting"])
@pytest.mark.parametrize("wait_for_connect", [1234, 4567])
def test_BootstrapSettings_BackupModifyRestore_SettingsMatch(self, workspace, wait_for_connect):
backup_path = workspace.tmp_path
# create backup
workspace.settings.backup_bootstrap_settings(backup_path)
# verify files match
bootstrap_settings = workspace.paths.bootstrap_config_file()
bootstrap_settings_backup = os.path.join(backup_path, '{}.bak'.format(os.path.basename(bootstrap_settings)))
assert os.path.exists(bootstrap_settings), "Bootstrap settings file does not exist"
assert os.path.exists(bootstrap_settings_backup), "Bootstrap settings backup does not exist"
assert filecmp.cmp(bootstrap_settings, bootstrap_settings_backup), "Bootstrap settings and backup do not match"
# modify settings
workspace.settings.modify_bootstrap_setting('remote_filesystem', 0)
workspace.settings.modify_bootstrap_setting('wait_for_connect', wait_for_connect)
workspace.settings.modify_bootstrap_setting('remote_ip', '0.36.27.18')
workspace.settings.modify_bootstrap_setting('additional_setting', 'value1')
# verify files are different
assert not filecmp.cmp(bootstrap_settings, bootstrap_settings_backup), "Modified bootstrap settings and backup" \
" are equal, they should be different"
# restore backup
workspace.settings.restore_bootstrap_settings(backup_path)
# verify files match
assert filecmp.cmp(bootstrap_settings, bootstrap_settings_backup), "Restored bootstrap settings and backup " \
"are different, they should be equal"
+2
View File
@@ -0,0 +1,2 @@
[pytest]
python_files = 'test_*.py' , '*_test.py' , '*_tests.py'
+10
View File
@@ -0,0 +1,10 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
@@ -0,0 +1,239 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for ly_test_tools._internal.managers.abstract_resource_locator
"""
import os
import unittest.mock as mock
import pytest
import ly_test_tools._internal.managers.abstract_resource_locator as abstract_resource_locator
pytestmark = pytest.mark.SUITE_smoke
mock_initial_path = "mock_initial_path"
mock_engine_root = "mock_engine_root"
mock_dev_path = "mock_dev_path"
mock_build_directory = 'mock_build_directory'
mock_project = 'mock_project'
class TestFindEngineRoot(object):
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.abspath')
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.exists')
def test_FindEngineRoot_InitialPathExists_ReturnsTuple(self, mock_path_exists, mock_abspath):
mock_path_exists.return_value = True
mock_abspath.return_value = mock_engine_root
engine_root, dev_path = abstract_resource_locator._find_engine_root(mock_initial_path)
assert engine_root == mock_engine_root
assert dev_path == mock_initial_path
mock_path_exists.assert_called_once()
mock_abspath.assert_called_once()
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.abspath')
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.exists')
def test_FindEngineRoot_InitialPathDoesntExist_RaisesOSError(self, mock_path_exists, mock_abspath):
mock_path_exists.return_value = False
mock_abspath.return_value = mock_engine_root
with pytest.raises(OSError):
abstract_resource_locator._find_engine_root(mock_initial_path)
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.abspath',
mock.MagicMock(return_value=mock_initial_path))
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
class TestAbstractResourceLocator(object):
def test_Init_HasEngineRoot_SetsAttrs(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
assert mock_abstract_resource_locator._build_directory == mock_build_directory
assert mock_abstract_resource_locator._engine_root == mock_engine_root
assert mock_abstract_resource_locator._dev_path == mock_dev_path
assert mock_abstract_resource_locator._project == mock_project
def test_BasePath_IsCalled_ReturnsBasePath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
assert mock_abstract_resource_locator.engine_root() == mock_engine_root
def test_Dev_IsCalled_ReturnsDevPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
assert mock_abstract_resource_locator.dev() == mock_dev_path
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.isfile')
def test_3rdParty_IsCalledHasTxtFile_Returns3rdPartyPath(self, mock_isfile):
mock_isfile.return_value = True
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator._engine_root, '3rdParty')
assert mock_abstract_resource_locator.third_party() == expected_path
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.isfile')
def test_3rdParty_IsCalledNoTxtFile_RaisesFileNotFoundError(self, mock_isfile):
mock_isfile.return_value = False
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
with pytest.raises(FileNotFoundError):
mock_abstract_resource_locator.third_party()
mock_isfile.assert_called_once()
def test_BuildDirectory_IsCalled_ReturnsBuildDirectoryPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
assert mock_abstract_resource_locator.build_directory() == mock_build_directory
def test_Project_IsCalled_ReturnsProjectPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.dev(), mock_project)
assert mock_abstract_resource_locator.project() == expected_path
def test_AssetProcessor_IsCalled_ReturnsAssetProcessorPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.build_directory(), 'AssetProcessor')
assert mock_abstract_resource_locator.asset_processor() == expected_path
def test_AssetProcessorBatch_IsCalled_ReturnsAssetProcessorBatchPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.build_directory(), 'AssetProcessorBatch')
assert mock_abstract_resource_locator.asset_processor_batch() == expected_path
def test_Editor_IsCalled_ReturnsEditorPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.build_directory(), 'Editor')
assert mock_abstract_resource_locator.editor() == expected_path
def test_Cache_IsCalled_ReturnsCachePath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.dev(), 'Cache')
assert mock_abstract_resource_locator.cache() == expected_path
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.exists')
def test_GetShaderCompilerPath_IsCalledExecutablePathExists_ReturnsGetShaderCompilerPath(self, mock_path_exists):
mock_path_exists.return_value = True
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.build_directory(), 'CrySCompileServer')
assert mock_abstract_resource_locator.get_shader_compiler_path() == expected_path
def test_GetShaderCompilerDir_IsCalled_ReturnsShaderCompilerDir(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = mock_abstract_resource_locator.build_directory()
assert mock_build_directory == expected_path
def test_ShaderCompilerConfigFile_IsCalled_ReturnsShaderCompilerConfigPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.build_directory(), 'config.ini')
assert mock_abstract_resource_locator.shader_compiler_config_file() == expected_path
def test_ShaderCache_IsCalled_ReturnsShaderCacheDir(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.build_directory(), 'Cache')
assert mock_abstract_resource_locator.shader_cache() == expected_path
def test_BootstrapConfigFile_IsCalled_ReturnBootstrapConfigFilePath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.dev(), 'bootstrap.cfg')
assert mock_abstract_resource_locator.bootstrap_config_file() == expected_path
def test_AssetProcessorConfigFile_IsCalled_ReturnsAssetProcessorConfigFilePath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.dev(), 'AssetProcessorPlatformConfig.ini')
assert mock_abstract_resource_locator.asset_processor_config_file() == expected_path
def test_AutoexecFile_IsCalled_ReturnsAutoexecFilePath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.dev(),
mock_abstract_resource_locator._project,
'autoexec.cfg')
assert mock_abstract_resource_locator.autoexec_file() == expected_path
def test_TestResults_IsCalled_TestResultsPath(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_abstract_resource_locator.dev(), 'TestResults')
assert mock_abstract_resource_locator.test_results() == expected_path
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.expanduser')
def test_DevicesFile_IsCalled_ReturnsDevicesFilePath(self, mock_expanduser):
mock_expanded_path = 'C:/somepath/'
mock_expanduser.return_value = mock_expanded_path
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
expected_path = os.path.join(mock_expanded_path, 'ly_test_tools', 'devices.ini')
assert mock_abstract_resource_locator.devices_file() == expected_path
def test_PlatformConfigFile_NotImplemented_RaisesNotImplementedError(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
with pytest.raises(NotImplementedError):
mock_abstract_resource_locator.platform_config_file()
def test_PlatformCache_NotImplemented_RaisesNotImplementedError(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
with pytest.raises(NotImplementedError):
mock_abstract_resource_locator.platform_cache()
def test_ProjectLog_NotImplemented_RaisesNotImplementedError(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
with pytest.raises(NotImplementedError):
mock_abstract_resource_locator.project_log()
def test_ProjectScreenshots_NotImplemented_RaisesNotImplementedError(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
with pytest.raises(NotImplementedError):
mock_abstract_resource_locator.project_screenshots()
def test_EditorLog_NotImplemented_RaisesNotImplementedError(self):
mock_abstract_resource_locator = abstract_resource_locator.AbstractResourceLocator(
mock_build_directory, mock_project)
with pytest.raises(NotImplementedError):
mock_abstract_resource_locator.editor_log()
@@ -0,0 +1,321 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for WorkspaceManager class
"""
import datetime
import os
import stat
import unittest.mock as mock
import unittest
import os
import pytest
import ly_test_tools._internal.managers.artifact_manager
pytestmark = pytest.mark.SUITE_smoke
TIMESTAMP_FORMAT = '%Y-%m-%dT%H-%M-%S-%f'
DATE = datetime.datetime.now().strftime(TIMESTAMP_FORMAT)
ROOT_LOG_FOLDER = os.path.join("TestResults", DATE, "pytest_results")
@mock.patch('os.makedirs', mock.MagicMock())
class TestSetTestName(unittest.TestCase):
def setUp(self):
self.mock_root = ROOT_LOG_FOLDER
self.mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(
self.mock_root)
@mock.patch('os.path.exists', mock.MagicMock(return_value=False))
def test_Init_NoPathExists_CreatesPathSetsAttributes(self):
create_amount = 1
updated_path = "{}".format(self.mock_artifact_manager.artifact_path, create_amount)
assert self.mock_artifact_manager.artifact_path == self.mock_root, (
'artifact_path does not match self.mock_root')
assert self.mock_artifact_manager.dest_path == updated_path, (
'dest_path does not match updated_path')
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Init_PathExists_NoPathCreatedSetsAttributes(self):
assert self.mock_artifact_manager.artifact_path == self.mock_root, (
'artifact_path does not match self.mock_root')
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match artifact_path')
@mock.patch('os.path.exists', mock.MagicMock(return_value=False))
def test_SetTestNameAmount_ValidNameNoPathExists_CreatesPathSetsAttributes(self):
create_amount = 5
test_name = 'dummy'
updated_path = "{}_1".format(os.path.join(self.mock_artifact_manager.artifact_path,
test_name))
self.mock_artifact_manager.set_test_name(test_name, create_amount)
assert self.mock_artifact_manager.dest_path == updated_path, (
'dest_path does not match updated_path')
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_SetTestNameAmount_ValidNamePathExists_NoPathCreatedSetsAttributes(self):
test_name = 'dummy'
create_amount = 5 # Ignored because the path already exists.
updated_path = "{}".format(os.path.join(self.mock_artifact_manager.artifact_path, test_name),
create_amount)
self.mock_artifact_manager.set_test_name(test_name, create_amount)
assert self.mock_artifact_manager.dest_path == updated_path, (
'dest_path does not match updated_path')
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
class TestSaveArtifact(unittest.TestCase):
def setUp(self):
self.mock_root = ROOT_LOG_FOLDER
self.mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(self.mock_root)
@mock.patch('shutil.copytree')
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_SaveArtifact_ArtifactNameIsNotNone_CopyTreeCalledCorrectly(self, mock_reducer, mock_path_isdir,
mock_copy_tree):
test_name = 'dummy'
mock_artifact_name = 'mock_artifact_name'
updated_path = "{}".format(os.path.join(self.mock_artifact_manager.artifact_path,
test_name))
mock_path_isdir.return_value = True
mock_copy_tree.return_value = True
mock_reducer.return_value = mock_artifact_name[:-5]
self.mock_artifact_manager.set_test_name(test_name)
self.mock_artifact_manager.save_artifact(updated_path, mock_artifact_name)
assert self.mock_artifact_manager.dest_path == updated_path, (
'dest_path does not match updated_path')
mock_copy_tree.assert_called_once_with(
updated_path, os.path.join(updated_path, mock_artifact_name[:-5]))
mock_reducer.assert_called_once_with(file_name=mock_artifact_name, max_length=25)
@mock.patch('shutil.copytree')
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_SaveArtifact_ArtifactNameIsNone_CopyTreeCalledCorrectly(self, mock_reducer, mock_path_isdir,
mock_copy_tree):
mock_artifact_name = None
mock_path_isdir.return_value = True
mock_copy_tree.return_value = True
self.mock_artifact_manager.save_artifact(self.mock_artifact_manager.artifact_path, mock_artifact_name)
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match self.mock_artifact_manager.artifact_path')
mock_copy_tree.assert_called_once_with(
self.mock_artifact_manager.artifact_path,
os.path.join(self.mock_artifact_manager.artifact_path, 'pytest_results'))
mock_reducer.assert_not_called()
@mock.patch('os.chmod', mock.MagicMock())
@mock.patch('shutil.copy')
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_SaveArtifact_ArtifactNameIsNotNone_CopyCalledCorrectly(self, mock_reducer, mock_path_isdir,
mock_copy):
mock_artifact_name = 'mock_artifact_name'
mock_path_isdir.return_value = False
mock_reducer.return_value = mock_artifact_name[:-5]
mock_copy.return_value = True
self.mock_artifact_manager.save_artifact(self.mock_artifact_manager.artifact_path, mock_artifact_name)
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match self.mock_artifact_manager.artifact_path')
mock_copy.assert_called_once_with(
self.mock_artifact_manager.artifact_path,
os.path.join(self.mock_artifact_manager.artifact_path, mock_artifact_name[:-5]))
mock_reducer.assert_called_once_with(file_name=mock_artifact_name, max_length=25)
@mock.patch('os.chmod', mock.MagicMock())
@mock.patch('shutil.copy')
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_SaveArtifact_ArtifactNameIsNone_CopyCalledCorrectly(self, mock_reducer, mock_path_isdir,
mock_copy):
mock_artifact_name = None
mock_path_isdir.return_value = False
mock_copy.return_value = True
self.mock_artifact_manager.save_artifact(self.mock_artifact_manager.artifact_path, mock_artifact_name)
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match self.mock_artifact_manager.artifact_path')
mock_copy.assert_called_once_with(
self.mock_artifact_manager.artifact_path,
os.path.join(self.mock_artifact_manager.artifact_path, 'pytest_results'))
mock_reducer.assert_not_called()
@mock.patch('shutil.copy', mock.MagicMock())
@mock.patch('os.chmod')
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_SaveArtifact_ArtifactNameIsNotNone_ChmodCalledCorrectly(self, mock_reducer, mock_path_isdir, mock_chmod):
mock_artifact_name = 'mock_artifact_name'
mock_path_isdir.return_value = False
mock_reducer.return_value = mock_artifact_name[:-5]
self.mock_artifact_manager.save_artifact(self.mock_artifact_manager.artifact_path, mock_artifact_name)
mock_chmod.assert_called_once_with(
os.path.join(self.mock_artifact_manager.artifact_path, mock_artifact_name[:-5]),
stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match self.mock_artifact_manager.artifact_path')
mock_reducer.assert_called_once_with(file_name=mock_artifact_name, max_length=25)
@mock.patch('shutil.copy', mock.MagicMock())
@mock.patch('os.chmod')
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_SaveArtifact_ArtifactNameIsNone_ChmodCalledCorrectly(self, mock_reducer, mock_path_isdir, mock_chmod):
mock_artifact_name = None
mock_path_isdir.return_value = False
self.mock_artifact_manager.save_artifact(self.mock_artifact_manager.artifact_path, mock_artifact_name)
mock_chmod.assert_called_once_with(
os.path.join(self.mock_artifact_manager.artifact_path, 'pytest_results'),
stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match self.mock_artifact_manager.artifact_path')
mock_reducer.assert_not_called()
@mock.patch('shutil.copy', mock.MagicMock())
@mock.patch('os.chmod', mock.MagicMock())
@mock.patch('ly_test_tools._internal.managers.artifact_manager.ArtifactManager._get_collision_handled_filename')
@mock.patch('os.path.isdir')
def test_SaveArtifact_DestinationCollides_CallsGetCollisionHandledFilename(self, mock_path_isdir, under_test):
mock_path_isdir.return_value = False
self.mock_artifact_manager.save_artifact(self.mock_artifact_manager.artifact_path)
under_test.assert_called_once()
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
class TestGenerateArtifactFileName(unittest.TestCase):
def setUp(self):
self.mock_root = ROOT_LOG_FOLDER
self.mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(self.mock_root)
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_GenerateFileName_HasArtifactName_ReturnsFilePath(self, mock_reducer, mock_path_isdir):
mock_artifact_name = 'mock_artifact_name'
mock_path_isdir.return_value = True
mock_artifact_file = self.mock_artifact_manager.generate_artifact_file_name(mock_artifact_name)
assert self.mock_artifact_manager.dest_path == self.mock_artifact_manager.artifact_path, (
'dest_path does not match self.mock_artifact_manager.artifact_path')
assert mock_artifact_file == os.path.join(self.mock_artifact_manager.artifact_path, mock_artifact_name), (
'mock_artifact_file does not match expected value from generate_artifact_file_name()')
mock_reducer.assert_not_called()
@mock.patch('os.path.isdir')
@mock.patch('ly_test_tools.environment.file_system.reduce_file_name_length')
def test_GenerateFileName_ArtifactNameIsNone_ReturnsFilePath(self, mock_reducer, mock_path_isdir):
mock_artifact_name = None
mock_path_isdir.return_value = True
with pytest.raises(ValueError):
self.mock_artifact_manager.generate_artifact_file_name(mock_artifact_name)
mock_reducer.assert_not_called()
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
class TestGatherArtifacts(unittest.TestCase):
def setUp(self):
self.mock_root = ROOT_LOG_FOLDER
self.mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(self.mock_root)
@mock.patch('shutil.make_archive')
def test_TestGatherArtifacts_TestNameIsNone_MakeArchiveCalled(self, mock_make_archive):
mock_destination = 'mock_destination'
self.mock_artifact_manager.gather_artifacts(mock_destination)
mock_make_archive.assert_called_once_with(mock_destination, 'zip', self.mock_artifact_manager.artifact_path)
@mock.patch('ly_test_tools.environment.file_system.sanitize_file_name')
@mock.patch('shutil.make_archive')
def test_TestGatherArtifacts_TestNameIsNotNone_MakeArchiveCalled(self, mock_make_archive, mock_sanitize_file_name):
test_name = 'dummy'
mock_destination = 'mock_destination'
mock_sanitize_file_name.return_value = test_name
self.mock_artifact_manager.set_test_name(test_name)
self.mock_artifact_manager.gather_artifacts(mock_destination)
mock_make_archive.assert_called_once_with(
mock_destination, 'zip',
os.path.join(self.mock_artifact_manager.artifact_path, test_name))
class TestGetCollisionHandledFilename(unittest.TestCase):
def setUp(self):
self.mock_root = ROOT_LOG_FOLDER
self.mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(self.mock_root)
def test_GetCollisionHandledFilename_AmountOne_ReturnsParam(self):
mock_file_path = 'foo'
amount = 1
under_test = self.mock_artifact_manager._get_collision_handled_filename(mock_file_path, amount)
assert under_test == mock_file_path
@mock.patch('os.path.exists', mock.MagicMock())
def test_GetCollisionHandledFilename_HasExt_RenameWithExtProperly(self):
mock_file_path = 'foo'
mock_file_ext = '.ext'
mock_file_with_ext = mock_file_path + mock_file_ext
amount = 2
under_test = self.mock_artifact_manager._get_collision_handled_filename(mock_file_with_ext, amount)
assert under_test == f'{mock_file_path}_{amount-1}{mock_file_ext}'
@mock.patch('os.path.exists', mock.MagicMock())
def test_GetCollisionHandledFilename_HasNoExt_RenameWithoutExtProperly(self):
mock_file_path = 'foo'
amount = 2
under_test = self.mock_artifact_manager._get_collision_handled_filename(mock_file_path, amount)
assert under_test == f'{mock_file_path}_{amount-1}'
@mock.patch('os.path.exists')
def test_GetCollisionHandledFilename_HasLargeAmountAndNotPathExists_EndsLoopEarly(self, mock_path_exists):
mock_file_path = 'foo'
amount = 99
mock_path_exists.side_effect = [True, True, False]
under_test = self.mock_artifact_manager._get_collision_handled_filename(mock_file_path, amount)
# The integer should be the index of False in mock_path_exists
assert under_test == f'{mock_file_path}_{3}'
@mock.patch('os.path.exists')
def test_GetCollisionHandledFilename_HasLargeAmountAndCollides_ReachesMaxAmount(self, mock_path_exists):
mock_file_path = 'foo'
amount = 99
mock_path_exists.return_value = True
under_test = self.mock_artifact_manager._get_collision_handled_filename(mock_file_path, amount)
assert under_test == f'{mock_file_path}_{amount-1}'
@@ -0,0 +1,216 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools.lumberyard.asset_processor
"""
import datetime
import unittest.mock as mock
import os
import pytest
import ly_test_tools._internal.managers.workspace
import ly_test_tools._internal.managers.abstract_resource_locator
import ly_test_tools.lumberyard.asset_processor
pytestmark = pytest.mark.SUITE_smoke
mock_initial_path = "mock_initial_path"
mock_engine_root = "mock_engine_root"
mock_dev_path = "mock_dev_path"
mock_build_directory = 'mock_build_directory'
mock_project = 'mock_project'
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.abspath',
mock.MagicMock(return_value=mock_initial_path))
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
@mock.patch('ly_test_tools.lumberyard.asset_processor.logger.warning', mock.MagicMock())
class TestAssetProcessor(object):
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
def test_Init_DefaultParams_MembersSetCorrectly(self, mock_workspace):
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
assert under_test._workspace == mock_workspace
assert under_test._port is not None
assert under_test._ap_proc is None
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.Popen')
@mock.patch('ly_test_tools.lumberyard.asset_processor.AssetProcessor.connect_socket')
@mock.patch('ly_test_tools.lumberyard.asset_processor.ASSET_PROCESSOR_PLATFORM_MAP', {'foo': 'bar'})
def test_Start_NoneRunning_ProcStarted(self, mock_connect, mock_popen, mock_workspace):
mock_ap_path = 'mock_ap_path'
mock_workspace.asset_processor_platform = 'foo'
mock_workspace.paths.asset_processor.return_value = mock_ap_path
mock_workspace.project = 'AutomatedTesting'
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
under_test.enable_asset_processor_platform = mock.MagicMock()
under_test.wait_for_idle = mock.MagicMock()
under_test.start(connect_to_ap=True)
assert under_test._ap_proc is not None
mock_popen.assert_called_once_with([mock_ap_path, '--zeroAnalysisMode', '--gamefolder', 'AutomatedTesting',
'--acceptInput', '--platforms', 'bar'], cwd=os.path.dirname(mock_ap_path))
mock_connect.assert_called()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.Popen')
@mock.patch('os.path.basename', mock.MagicMock(return_value=""))
@mock.patch('os.path.dirname', mock.MagicMock(return_value=""))
@mock.patch('ly_test_tools.environment.process_utils.kill_processes_with_name_not_started_from', mock.MagicMock(return_value=None))
@mock.patch('ly_test_tools.environment.process_utils.process_exists', mock.MagicMock(return_value=True))
@mock.patch('socket.socket.connect')
def test_Start_ProcAlreadyRunning_ProcNotChanged(self, mock_connect, mock_popen, mock_workspace):
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
under_test.process_exists = mock.MagicMock(return_value=True)
under_test.asset_processor_platform = mock.MagicMock(return_value=ly_test_tools.HOST_OS_PLATFORM)
mock_proc = mock.MagicMock()
under_test._ap_proc = mock_proc
under_test.start(connect_to_ap=True)
assert under_test._ap_proc == mock_proc
mock_popen.assert_not_called()
mock_connect.assert_not_called()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('ly_test_tools.lumberyard.asset_processor.waiter.wait_for')
def test_Stop_ProcAlreadyRunning_ProcStopped(self, mock_waiter, mock_workspace):
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
under_test.get_process_list = mock.MagicMock(return_value=[mock.MagicMock()])
under_test._control_connection = mock.MagicMock()
under_test.get_pid = mock.MagicMock(return_value=0)
under_test.send_quit = mock.MagicMock(return_value=True)
mock_proc = mock.MagicMock()
under_test._ap_proc = mock_proc
under_test.stop()
under_test.send_quit.assert_called_once()
mock_waiter.assert_called_once()
assert under_test._ap_proc is None
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.run')
def test_BatchProcess_NoFastscanBatchCompletes_Success(self, mock_run, mock_workspace):
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
mock_workspace.project = None
apb_path = mock_workspace.paths.asset_processor_batch()
mock_run.return_value.returncode = 0
result, _ = under_test.batch_process(1, False)
assert result
mock_run.assert_called_once_with([apb_path], close_fds=True, capture_output=False,
timeout=1)
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.run')
def test_BatchProcess_FastscanBatchCompletes_Success(self, mock_run, mock_workspace):
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
mock_workspace.project = 'AutomatedTesting'
apb_path = mock_workspace.paths.asset_processor_batch()
mock_run.return_value.returncode = 0
result = under_test.batch_process(1, True)
assert result
mock_run.assert_called_once_with([apb_path, '--zeroAnalysisMode', '--gamefolder', 'AutomatedTesting'],
close_fds=True, capture_output=False,
timeout=1)
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.run')
def test_BatchProcess_ReturnCodeFail_Failure(self, mock_run, mock_workspace):
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
mock_workspace.project = None
apb_path = mock_workspace.paths.asset_processor_batch()
mock_run.return_value.returncode = 1
result, _ = under_test.batch_process(None, False)
assert not result
mock_run.assert_called_once_with([apb_path], close_fds=True, capture_output=False, timeout=28800.0)
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('os.path.isfile')
@mock.patch('ly_test_tools.lumberyard.asset_processor.file_system.unlock_file')
def test_EnableAssetProcessorPlatform_PlatformInConfig_ConfigUpdated(self, mock_unlock, mock_isfile,
mock_workspace):
mock_isfile.return_value = True
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
apconfig_path = mock_workspace.paths.asset_processor_config_file()
mock_config_content = '[Platforms]\n;foo\n;bar\n[Other]\nsomething\n'
mock_open_config = mock.mock_open(read_data=mock_config_content)
patcher = mock.patch('builtins.open', mock_open_config)
patcher.start()
under_test.enable_asset_processor_platform('foo')
mock_unlock.assert_called_once_with(apconfig_path)
mock_open_config.assert_called_once_with(apconfig_path, 'r+')
file_handle = mock_open_config()
file_handle.writelines.assert_called_once_with(['[Platforms]\n', 'foo\n', ';bar\n', '[Other]\n', 'something\n'])
patcher.stop()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('os.path.isfile')
@mock.patch('ly_test_tools.lumberyard.asset_processor.file_system.unlock_file')
def test_EnableAssetProcessorPlatform_FileDoesNotExist_ErrorRaised(self, mock_unlock, mock_isfile, mock_workspace):
mock_isfile.return_value = False
under_test = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
apconfig_path = mock_workspace.paths.asset_processor_config_file()
with pytest.raises(IOError):
under_test.enable_asset_processor_platform('foo')
mock_isfile.assert_called_once_with(apconfig_path)
mock_unlock.assert_not_called()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
def test_BackupAPSettings_Called_CallsBackupAPSettings(self, mock_workspace):
mock_temp_path = 'foo_path'
mock_asset_processor = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
mock_workspace.settings.get_temp_path.return_value = mock_temp_path
mock_asset_processor.backup_ap_settings()
mock_workspace.settings.backup_asset_processor_settings.assert_called_with(mock_temp_path)
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
def test_RestoreAPSettings_Called_CallsRestoreAPSettings(self, mock_workspace):
mock_temp_path = 'foo_path'
mock_asset_processor = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
mock_workspace.settings.get_temp_path.return_value = mock_temp_path
mock_asset_processor.restore_ap_settings()
mock_workspace.settings.restore_asset_processor_settings.assert_called_with(mock_temp_path)
@mock.patch('ly_test_tools.lumberyard.asset_processor.AssetProcessor.restore_ap_settings')
@mock.patch('ly_test_tools.lumberyard.asset_processor.AssetProcessor.stop')
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
def test_Teardown_Called_CallsRestoreAPSettingsAndStop(self, mock_workspace, mock_stop, mock_restore_ap):
mock_asset_processor = ly_test_tools.lumberyard.asset_processor.AssetProcessor(mock_workspace)
mock_asset_processor.teardown()
mock_stop.assert_called()
mock_restore_ap.assert_called()
@@ -0,0 +1,149 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools.builtin.helpers functions.
"""
import unittest.mock as mock
import pytest
import ly_test_tools.builtin.helpers
import ly_test_tools._internal.managers.abstract_resource_locator
import ly_test_tools._internal.managers.workspace
import ly_test_tools._internal.managers.platforms.mac
import ly_test_tools._internal.managers.platforms.windows
from ly_test_tools import MAC, WINDOWS
pytestmark = pytest.mark.SUITE_smoke
class MockedAbstractResourceLocator(ly_test_tools._internal.managers.abstract_resource_locator.AbstractResourceLocator):
def __init__(self, build_directory, project):
super(MockedAbstractResourceLocator, self).__init__(
build_directory=build_directory,
project=project,
)
class MockedWorkspaceManager(ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager):
def __init__(
self, resource_locator, project, tmp_path, output_path
):
super(MockedWorkspaceManager, self).__init__(
resource_locator=resource_locator,
project=project,
tmp_path=tmp_path,
output_path=output_path
)
@mock.patch(
'ly_test_tools._internal.managers.abstract_resource_locator.AbstractResourceLocator',
mock.MagicMock(return_value=MockedAbstractResourceLocator)
)
@mock.patch('os.remove', mock.MagicMock(return_value=True))
class TestBuiltinHelpers(object):
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager',
mock.MagicMock(return_value=MockedWorkspaceManager))
@mock.patch('ly_test_tools.builtin.helpers.WINDOWS', True)
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_CreateBuiltinWorkspace_WindowsOS_ReturnsWindowsWorkspaceManager(self):
expected_workspace = ly_test_tools._internal.managers.platforms.windows.WindowsWorkspaceManager
under_test = ly_test_tools.builtin.helpers.create_builtin_workspace(
build_directory='build_directory',
project='mock_project',
tmp_path='mock_tmp_path',
output_path='mock_output_path',
)
assert type(under_test) == expected_workspace
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager',
mock.MagicMock(return_value=MockedWorkspaceManager))
@mock.patch('ly_test_tools.builtin.helpers.MAC', True)
@mock.patch('ly_test_tools.builtin.helpers.WINDOWS', False)
def test_CreateBuiltinWorkspace_MacOS_ReturnsMacWorkspaceManager(self):
expected_workspace = ly_test_tools._internal.managers.platforms.mac.MacWorkspaceManager
under_test = ly_test_tools.builtin.helpers.create_builtin_workspace(
build_directory='build_directory',
project='mock_project',
tmp_path='mock_tmp_path',
output_path='mock_output_path',
)
assert type(under_test) == expected_workspace
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager',
mock.MagicMock(return_value=MockedWorkspaceManager))
@mock.patch('ly_test_tools._internal.pytest_plugin.build_directory', None)
def test_CreateBuiltinWorkspace_InvalidPlatform_RaisesValueError(self):
with pytest.raises(ValueError):
ly_test_tools.builtin.helpers.create_builtin_workspace(
build_directory=None,
project='mock_project',
tmp_path='mock_tmp_path',
output_path='mock_output_path',
)
@mock.patch('os.path.abspath', mock.MagicMock(return_value='mock_base_dir'))
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_FindEngineRoot_HasRootFile_ReturnsTuple(self):
under_test = ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root(
initial_path='mock_dev_dir')
assert under_test == ('mock_base_dir', 'mock_dev_dir')
@mock.patch('os.path.abspath', mock.MagicMock(return_value='mock_base_dir'))
@mock.patch('os.path.exists', mock.MagicMock(return_value=False))
def test_FindEngineRoot_NoRootFile_RaisesOSError(self):
with pytest.raises(OSError):
ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root(
initial_path='mock_dev_dir')
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager.setup')
@mock.patch('ly_test_tools._internal.managers.artifact_manager.NullArtifactManager', mock.MagicMock())
@mock.patch('ly_test_tools.builtin.helpers.setup_bootstrap_project', mock.MagicMock(return_value=None))
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_SetupBuiltinWorkspace_ValidWorkspaceSetup_ReturnsWorkspaceObject(self, mock_setup):
mock_test_name = 'mock_test_name'
mock_test_amount = 10
mock_workspace = ly_test_tools.builtin.helpers.create_builtin_workspace(
build_directory='build_directory',
project='mock_project',
tmp_path='mock_tmp_path',
output_path='mock_output_path',
)
under_test = ly_test_tools.builtin.helpers.setup_builtin_workspace(
mock_workspace, mock_test_name, mock_test_amount)
assert under_test == mock_workspace
assert mock_setup.call_count == 1
mock_workspace.artifact_manager.set_test_name.assert_called_with(
test_name=mock_test_name, amount=mock_test_amount)
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager.teardown')
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_TeardownBuiltinWorkspace_ValidWorkspaceSetup_ReturnsWorkspaceObject(self, mock_teardown):
mock_workspace = ly_test_tools.builtin.helpers.create_builtin_workspace(
build_directory='build_directory',
project='mock_project',
tmp_path='mock_tmp_path',
output_path='mock_output_path',
)
under_test = ly_test_tools.builtin.helpers.teardown_builtin_workspace(mock_workspace)
assert under_test == mock_workspace
assert mock_teardown.call_count == 1
@@ -0,0 +1,139 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.pytest_plugin.case_id
"""
import unittest.mock as mock
import pytest
import ly_test_tools._internal.pytest_plugin.case_id as case_id
pytestmark = pytest.mark.SUITE_smoke
class TestCaseId(object):
def test_Configure_MockConfig_ValuesAdded(self):
mock_config = mock.MagicMock()
case_id.pytest_configure(mock_config)
mock_config.addinivalue_line.assert_called_once()
def test_AddOption_MockParser_OptionsAdded(self):
mock_parser = mock.MagicMock()
case_id.pytest_addoption(mock_parser)
mock_parser.addoption.assert_called_once()
def test_MakeReportImpl_MarkerSimpleItem_TestCaseIdAdded(self):
mock_report = mock.MagicMock()
mock_report.when = 'call'
mock_item = mock.MagicMock()
mock_marker = mock.MagicMock()
test_case = 123
test_case_list = [str(test_case)]
mock_marker.args = test_case_list
mock_item.get_marker.return_value = mock_marker
mock_xml = mock.MagicMock()
mock_item.config._xml = mock_xml
mock_node = mock.MagicMock()
mock_xml.node_reporter.return_value = mock_node
case_id._pytest_runtest_makereport_imp(mock_report, mock_item)
mock_node.add_property.assert_called_once_with('test_case_id', test_case)
def test_MakeReportImpl_ClosestMarkerSimpleItem_TestCaseIdAdded(self):
mock_report = mock.MagicMock()
mock_report.when = 'call'
mock_item = mock.MagicMock()
mock_marker = mock.MagicMock()
test_case = 123
test_case_list = [str(test_case)]
mock_marker.args = test_case_list
mock_item.get_marker.side_effect = AttributeError()
mock_item.get_closest_marker.return_value = mock_marker
mock_xml = mock.MagicMock()
mock_item.config._xml = mock_xml
mock_node = mock.MagicMock()
mock_xml.node_reporter.return_value = mock_node
case_id._pytest_runtest_makereport_imp(mock_report, mock_item)
mock_item.get_closest_marker.assert_called_once()
mock_node.add_property.assert_called_once_with('test_case_id', test_case)
@mock.patch('ly_test_tools._internal.pytest_plugin.case_id.log')
def test_MakeReportImpl_XmlReportError_WarningLogged(self, mock_logger):
mock_report = mock.MagicMock()
mock_report.when = 'call'
mock_item = mock.MagicMock()
mock_marker = mock.MagicMock()
test_case = '123'
test_case_list = [str(test_case)]
mock_marker.args = test_case_list
mock_item.get_marker.return_value = mock_marker
mock_item.config = ''
case_id._pytest_runtest_makereport_imp(mock_report, mock_item)
mock_logger.warning.assert_called_once()
def test_CollectionModifyItems_SingleTestCase_ItemsUpdated(self):
ids = [12, 34, 56]
id_to_filter = 34
items = []
deselected_items = []
for id in ids:
mock_item = mock.MagicMock()
mock_marker = mock.MagicMock()
mock_marker.args = [str(id)]
mock_item.get_marker.return_value = mock_marker
items.append(mock_item)
if id != id_to_filter:
deselected_items.append(mock_item)
mock_config = mock.MagicMock()
mock_config.option.test_case_ids = str(id_to_filter)
case_id.pytest_collection_modifyitems(items, mock_config)
assert len(items) == 1
assert items[0].get_marker().args[0] == str(id_to_filter)
mock_config.hook.pytest_deselected.assert_called_once_with(items=set(deselected_items))
def test_ParseTestCaseIds_ValidStringIds_IdsParsed(self):
ids = ['12', '34', '56']
expected = {12, 34, 56}
actual = case_id._parse_test_case_ids(ids)
assert expected == actual
def test_ParseTestCaseIds_ValidIntIds_IdsParsed(self):
ids = [12, 34, 56]
expected = {12, 34, 56}
actual = case_id._parse_test_case_ids(ids)
assert expected == actual
def test_ParseTestCaseIds_InvalidIds_ExceptionRaised(self):
ids = ['']
with pytest.raises(case_id.TestCaseIDException):
case_id._parse_test_case_ids(ids)
@@ -0,0 +1,144 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.pytest_plugin.failed_test_rerun_command
"""
import os
import pytest
import unittest.mock as mock
import ly_test_tools._internal.pytest_plugin.failed_test_rerun_command as failed_test_rerun_command
pytestmark = pytest.mark.SUITE_smoke
class TestRerunCommand(object):
MOCK_FOO_EXE = os.path.join('foo', 'path')
@mock.patch('os.path.join')
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_GetLauncherCommand_PythonScriptFound_CmdReturned(self, mock_join):
mock_python = 'python'
mock_join.return_value = mock_python
expected = f"{mock_python} -m pytest "
under_test = failed_test_rerun_command._get_test_launcher_cmd()
assert under_test == expected
@mock.patch('os.path.abspath', mock.MagicMock())
@mock.patch('os.path.join', mock.MagicMock())
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.sys.executable', MOCK_FOO_EXE)
@mock.patch('os.path.exists', mock.MagicMock(return_value=False))
def test_GetLauncherCommand_PythonScriptNotFound_ExeReturned(self):
expected = f"{TestRerunCommand.MOCK_FOO_EXE} -m pytest "
under_test = failed_test_rerun_command._get_test_launcher_cmd()
assert under_test == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.sys.executable', MOCK_FOO_EXE)
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.WINDOWS', True)
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_GetLauncherCommand_WindowsPythonInterpreter_WindowsPythonEntrypointReturned(self):
python_script = 'python3.cmd'
expected = f"{os.path.join(TestRerunCommand.MOCK_FOO_EXE, python_script)} -m pytest "
under_test = failed_test_rerun_command._get_test_launcher_cmd()
assert under_test == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.sys.executable', MOCK_FOO_EXE)
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.WINDOWS', False)
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_GetLauncherCommand_NonWindowsPythonInterpreter_NonWindowsPythonEntrypointReturned(self):
python_script = 'python3.sh'
expected = f"{os.path.join(TestRerunCommand.MOCK_FOO_EXE, python_script)} -m pytest "
under_test = failed_test_rerun_command._get_test_launcher_cmd()
assert under_test == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.abspath')
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.dirname')
def test_FormatCommand_FileAsPathAndFullNodeId_CorrectCommand(self, mock_dirname, mock_abspath):
launcher_cmd = 'python -m pytest '
test_path = os.path.join('dirA', 'dirB', 'test_stuff.py')
nodeid = 'test_stuff.py::test_Stuff_Something_Else[a]'
mock_dirname.return_value = test_path
mock_abspath.return_value = os.path.join(test_path, nodeid)
expected = f'{launcher_cmd}{os.path.join(test_path, nodeid)}'
actual = failed_test_rerun_command._format_cmd(launcher_cmd, test_path, nodeid)
assert actual == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.abspath')
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.dirname')
def test_FormatCommand_FileAsPathAndFileAsNodeId_CorrectCommand(self, mock_dirname, mock_abspath):
launcher_cmd = 'python -m pytest '
test_path = os.path.join('dirA', 'dirB', 'test_stuff.py')
nodeid = 'test_stuff.py'
mock_dirname.return_value = test_path
mock_abspath.return_value = os.path.join(test_path, nodeid)
expected = f'{launcher_cmd}{test_path}'
actual = failed_test_rerun_command._format_cmd(launcher_cmd, test_path, nodeid)
assert actual == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.abspath')
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.dirname')
def test_FormatCommand_DirectoryAsPathAndFullNodeId_CorrectCommand(self, mock_dirname, mock_abspath):
launcher_cmd = 'python -m pytest '
test_path = os.path.join('dirA', 'dirB')
nodeid = 'test_Stuff_Something_Else[a]'
mock_dirname.return_value = test_path
mock_abspath.return_value = os.path.join(test_path, os.path.normpath(nodeid))
expected = f'{launcher_cmd}{os.path.join(test_path, nodeid)}'
actual = failed_test_rerun_command._format_cmd(launcher_cmd, test_path, nodeid)
assert actual == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.abspath')
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.dirname')
def test_FormatCommand_DirectoryAsPathAndFileAsNodeId_CorrectCommand(self, mock_dirname, mock_abspath):
launcher_cmd = 'python -m pytest '
test_path = os.path.join('dirA', 'dirB')
nodeid = 'test_stuff.py'
mock_dirname.return_value = test_path
mock_abspath.return_value = os.path.join(test_path, os.path.normpath(nodeid))
expected = f'{launcher_cmd}{os.path.join(test_path, nodeid)}'
actual = failed_test_rerun_command._format_cmd(launcher_cmd, test_path, nodeid)
assert actual == expected
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command._get_test_launcher_cmd')
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.abspath')
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.path.dirname')
@mock.patch('os.path.exists', mock.MagicMock(return_value=False))
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.os.environ', {})
def test_BuildCommands_TwoNodeIds_CorrectCommands(self, mock_dirname, mock_abspath, mock_get_test_launcher_cmd):
test_path = os.path.join('dirA', 'dirB')
nodeids = ['test_stuff.py', 'test_something::test_Something_Somewhere_Somehow[a]']
python_cmd = 'python -m pytest '
mock_get_test_launcher_cmd.return_value = python_cmd
mock_dirname.side_effect = [test_path, test_path]
mock_abspath.side_effect = [os.path.join(test_path, os.path.normpath(nodeids[0])),
os.path.join(test_path, os.path.normpath(nodeids[1]))]
expected = [f'{python_cmd}{os.path.join(test_path, nodeids[0])}',
f'{python_cmd}{os.path.join(test_path, nodeids[1])}']
actual = failed_test_rerun_command.build_rerun_commands(test_path, nodeids)
assert actual == expected
@@ -0,0 +1,919 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import errno
import logging
import os
import psutil
import subprocess
import sys
import tarfile
import unittest.mock as mock
import unittest
import zipfile
import pytest
from ly_test_tools.environment import file_system
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.SUITE_smoke
class TestCheckFreeSpace(unittest.TestCase):
def setUp(self):
# sdiskusage is: (total, used, free, percent)
self.disk_usage = psutil._common.sdiskusage(100 * file_system.ONE_GIB, 0, 100 * file_system.ONE_GIB, 100)
def tearDown(self):
self.disk_usage = None
@mock.patch('psutil.disk_usage')
def test_CheckFreeSpace_NoSpace_RaisesIOError(self, mock_disk_usage):
total = 100 * file_system.ONE_GIB
used = total
mock_disk_usage.return_value = psutil._common.sdiskusage(total, used, total - used, used / total)
with self.assertRaises(IOError):
file_system.check_free_space('', 1, 'Raise')
@mock.patch('psutil.disk_usage')
def test_CheckFreeSpace_EnoughSpace_NoRaise(self, mock_disk_usage):
total = 100 * file_system.ONE_GIB
needed = 1
used = total - needed
mock_disk_usage.return_value = psutil._common.sdiskusage(total, used, total - used, used / total)
dest_path = 'dest'
file_system.check_free_space(dest_path, needed, 'No Raise')
mock_disk_usage.assert_called_once_with(dest_path)
class TestSafeMakedirs(unittest.TestCase):
@mock.patch('os.makedirs')
def test_SafeMakedirs_RaisedOSErrorErrnoEEXIST_DoesNotPropagate(self, mock_makedirs):
error = OSError()
error.errno = errno.EEXIST
mock_makedirs.side_effect = error
file_system.safe_makedirs('')
@mock.patch('os.makedirs')
def test_SafeMakedirs_RaisedOSErrorNotErrnoEEXIST_Propagates(self, mock_makedirs):
error = OSError()
error.errno = errno.EINTR
mock_makedirs.side_effect = error
with self.assertRaises(OSError):
file_system.safe_makedirs('')
@mock.patch('os.makedirs')
def test_SafeMakedirs_RootDir_DoesNotPropagate(self, mock_makedirs):
error = OSError()
error.errno = errno.EACCES
if sys.platform == 'win32':
mock_makedirs.side_effect = error
file_system.safe_makedirs('C:\\')
class TestGetNewestFileInDir(unittest.TestCase):
@mock.patch('glob.iglob')
def test_GetNewestFileInDir_NoResultsFound_ReturnsNone(self, mock_glob):
mock_glob.return_value.iglob = None
result = file_system.get_newest_file_in_dir('', '')
self.assertEqual(result, None)
@mock.patch('os.path.getctime')
@mock.patch('glob.iglob')
def test_GetNewestFileInDir_TwoResultsFound_ReturnsNewer(self, mock_glob, mock_ctime):
mock_glob.return_value = ['fileA.zip', 'fileB.zip']
mock_ctime.side_effect = [1, 2]
result = file_system.get_newest_file_in_dir('', [''])
self.assertEqual(result, 'fileB.zip')
@mock.patch('os.path.getctime')
@mock.patch('glob.iglob')
def test_GetNewestFileInDir_ThreeResultsTwoExts_CtimeCalledSixTimes(self, mock_glob, mock_ctime):
mock_glob.return_value = ['fileA.zip', 'fileB.zip', 'fileC.zip']
mock_ctime.side_effect = range(6)
file_system.get_newest_file_in_dir('', ['.zip', '.tgz'])
self.assertEqual(len(mock_ctime.mock_calls), 6)
class TestUnZip(unittest.TestCase):
decomp_obj_name = 'zipfile.ZipFile'
def setUp(self):
self.file_list = []
for i in range(25):
new_src_info = zipfile.ZipInfo('{}.txt'.format(i))
new_src_info.file_size = i
self.file_list.append(new_src_info)
self.mock_handle = mock.MagicMock()
self.mock_handle.infolist.return_value = self.file_list
self.mock_handle.__enter__.return_value = self.mock_handle
self.mock_decomp = mock.MagicMock()
self.mock_decomp.return_value = self.mock_handle
self.src_path = 'src.zip'
self.dest_path = 'dest'
self.exists = False
def tearDown(self):
self.mock_handle = None
self.mock_decomp = None
def call_decomp(self, dest, src, force=True, allow_exists=False):
return file_system.unzip(dest, src, force, allow_exists)
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch(decomp_obj_name)
def test_Unzip_DefaultArgs_CallsDecompressorWithSrc(self, mock_decomp, mock_join, mock_check_free, mock_exists):
mock_exists.return_value = self.exists
self.call_decomp(self.dest_path, self.src_path)
mock_decomp.assert_called_once_with(self.src_path, 'r')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_DefaultArgs_CheckFreeSpaceCalledOnceWithOneGiBAdded(self, mock_join,
mock_check_free, mock_exists):
mock_exists.return_value = self.exists
total_size = sum(info.file_size for info in self.file_list)
with mock.patch(self.decomp_obj_name, self.mock_decomp):
self.call_decomp(self.dest_path, self.src_path)
mock_check_free.assert_called_once_with(self.dest_path,
total_size + file_system.ONE_GIB,
'Not enough space to safely extract: ')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_DefaultArgs_JoinCalledWithNoPathNoExtension(self, mock_join, mock_check_free, mock_exists):
expected_name, _ = os.path.splitext(self.src_path)
mock_exists.return_value = self.exists
with mock.patch(self.decomp_obj_name, self.mock_decomp):
self.call_decomp(self.dest_path, self.src_path)
mock_join.assert_called_once_with(self.dest_path, expected_name)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_DefaultArgs_ReturnsCorrectPath(self, mock_join, mock_check_free):
build_name = 'build_name'
expected_path = self.dest_path+'\\'+build_name
mock_join.return_value = expected_path
path = ''
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
self.assertEqual(path, expected_path)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_ReleaseBuild_ReturnsCorrectPath(self, mock_join, mock_check_free):
build_name = 'lumberyard-1.2.0.3-54321-pc-1234'
expected_path = self.dest_path + '\\' + build_name
mock_join.return_value = expected_path
path = ''
self.src_path = r'C:\packages\lumberyard-1.2.0.3-54321-pc-1234.zip'
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
self.assertEqual(path, expected_path)
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_ReleaseBuild_JoinCalledWithNoPathNoExtension(self, mock_join, mock_check_free, mock_exists):
path = ''
self.src_path = r'C:\packages\lumberyard-1.2.0.3-54321-pc-1234.zip'
mock_exists.return_value = self.exists
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
mock_join.assert_called_once_with(self.dest_path, 'lumberyard-1.2.0.3-54321-pc-1234')
@mock.patch('ly_test_tools.environment.file_system.logger')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_BuildDirExistsForceAndAllowExistsNotSet_CRITICALLogged(self, mock_join, mock_check_free,
mock_exists, mock_log):
force = False
allow_exists = False
self.exists = True
mock_exists.return_value = self.exists
level = logging.getLevelName("CRITICAL")
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path, force)
mock_log.log.assert_called_with(level, 'Found existing {}. Will not overwrite.'.format(path))
@mock.patch('ly_test_tools.environment.file_system.logger')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_AllowExistsSet_INFOLogged(self, mock_join, mock_check_free, mock_exists, mock_log):
force = False
allow_exists = True
self.exists = True
mock_exists.return_value = self.exists
level = logging.getLevelName("INFO")
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path, force, allow_exists)
mock_log.log.assert_called_with(level, 'Found existing {}. Will not overwrite.'.format(path))
@mock.patch('ly_test_tools.environment.file_system.logger')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
def test_Unzip_BuildDirExistsForceSetTrue_INFOLogged(self, mock_join, mock_check_free, mock_exists, mock_log):
path = ''
self.exists = True
mock_exists.return_value = self.exists
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
mock_log.info.assert_called_once()
class TestUnTgz(unittest.TestCase):
decomp_obj_name = 'tarfile.open'
def setUp(self):
self.file_list = []
for i in range(25):
new_src_info = tarfile.TarInfo('{}.txt'.format(i))
new_src_info.size = i
self.file_list.append(new_src_info)
self.mock_handle = mock.MagicMock()
self.mock_handle.__iter__.return_value = self.file_list
self.mock_handle.__enter__.return_value = self.mock_handle
self.mock_decomp = mock.MagicMock()
self.mock_decomp.return_value = self.mock_handle
self.src_path = 'src.tgz'
self.dest_path = 'dest'
# os.stat_result is (mode, inode, device, hard links, owner uid, size, atime, mtime, ctime)
self.src_stat = os.stat_result((0, 0, 0, 0, 0, 0, file_system.ONE_GIB, 0, 0, 0))
def tearDown(self):
self.mock_decomp = None
self.mock_handle = None
def call_decomp(self, dest, src, exact=False, force=True, allow_exists=False):
return file_system.untgz(dest, src, exact, force, allow_exists)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch(decomp_obj_name)
@mock.patch('os.stat')
def test_UnTgz_DefaultArgs_CallsDecompressorWithSrc(self, mock_stat, mock_decomp, mock_join, mock_check_free):
mock_stat.return_value = self.src_stat
self.call_decomp(self.dest_path, self.src_path)
mock_decomp.assert_called_once_with(self.src_path)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_UnTgz_DefaultArgs_CheckFreeSpaceCalledOnceWithOneGiBAdded(self, mock_stat, mock_join, mock_check_free):
mock_stat.return_value = self.src_stat
total_size = sum(info.size for info in self.file_list)
with mock.patch(self.decomp_obj_name, self.mock_decomp):
self.call_decomp(self.dest_path, self.src_path, True)
mock_check_free.assert_called_once_with(self.dest_path,
total_size + file_system.ONE_GIB,
'Not enough space to safely extract: ')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_UnTgz_DefaultArgs_JoinCalledWithNoPathNoExtension(self, mock_stat, mock_join, mock_check_free):
expected_path, _ = os.path.splitext(os.path.basename(self.src_path))
mock_stat.return_value = self.src_stat
with mock.patch(self.decomp_obj_name, self.mock_decomp):
self.call_decomp(self.dest_path, self.src_path)
mock_join.assert_called_once_with(self.dest_path, expected_path)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_Untgz_DefaultArgs_ReturnsCorrectPath(self, mock_stat, mock_join, mock_check_free):
build_name = 'build_name'
expected_path = self.dest_path+'\\'+build_name
mock_join.return_value = expected_path
mock_stat.return_value = self.src_stat
path = ''
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
self.assertEqual(path, expected_path)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_Untgz_ReleaseBuild_ReturnsCorrectPath(self, mock_stat, mock_join, mock_check_free):
build_name = 'lumberyard-1.2.0.3-54321-pc-1234'
expected_path = self.dest_path + '\\' + build_name
mock_join.return_value = expected_path
mock_stat.return_value = self.src_stat
path = ''
self.src_path = r'C:\packages\lumberyard-1.2.0.3-54321-pc-1234.zip'
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
self.assertEqual(path, expected_path)
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_Untgz_ReleaseBuild_JoinCalledWithNoPathNoExtension(self, mock_stat, mock_join, mock_check_free):
mock_stat.return_value = self.src_stat
path = ''
self.src_path = r'C:\packages\lumberyard-1.2.0.3-54321-pc-1234.zip'
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
mock_join.assert_called_once_with(self.dest_path, 'lumberyard-1.2.0.3-54321-pc-1234')
@mock.patch('ly_test_tools.environment.file_system.logger')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_Untgz_BuildDirExistsForceAndAllowExistsNotSet_CRITICALLogged(self, mock_stat, mock_join,
mock_check_free, mock_exists, mock_log):
force = False
mock_exists.return_value = True
mock_stat.return_value = self.src_stat
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path, False, force)
level = logging.getLevelName("CRITICAL")
mock_log.log.assert_called_with(level, 'Found existing {}. Will not overwrite.'.format(path))
@mock.patch('ly_test_tools.environment.file_system.logger')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_Untgz_AllowExiststSet_INFOLogged(self, mock_stat, mock_join, mock_check_free, mock_exists, mock_log):
allow_exists = True
force = False
mock_exists.return_value = True
mock_stat.return_value = self.src_stat
level = logging.getLevelName("INFO")
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path, False, force, allow_exists)
mock_log.log.assert_called_with(level, 'Found existing {}. Will not overwrite.'.format(path))
@mock.patch('ly_test_tools.environment.file_system.logger')
@mock.patch('os.path.exists')
@mock.patch('ly_test_tools.environment.file_system.check_free_space')
@mock.patch('os.path.join')
@mock.patch('os.stat')
def test_Untgz_BuildDirExistsForceSetTrue_INFOLogged(self, mock_stat, mock_join,
mock_check_free, mock_exists, mock_log):
path = ''
mock_exists.return_value = True
mock_stat.return_value = self.src_stat
with mock.patch(self.decomp_obj_name, self.mock_decomp):
path = self.call_decomp(self.dest_path, self.src_path)
mock_log.info.assert_called_once()
class TestChangePermissions(unittest.TestCase):
def setUp(self):
# Create a mock of a os.walk return iterable.
self.root = 'root'
self.dirs = ['dir1', 'dir2']
self.files = ['file1', 'file2']
self.walk_iter = iter([(self.root, self.dirs, self.files)])
def tearDown(self):
self.root = None
self.dirs = None
self.files = None
self.walk_iter = None
@mock.patch('os.walk')
@mock.patch('os.chmod')
def test_ChangePermissions_DefaultValues_ChmodCalledCorrectly(self, mock_chmod, mock_walk):
os.walk.return_value = self.walk_iter
file_system.change_permissions('.', 0o777)
self.assertEqual(mock_chmod.mock_calls, [mock.call(os.path.join(self.root, self.dirs[0]), 0o777),
mock.call(os.path.join(self.root, self.dirs[1]), 0o777),
mock.call(os.path.join(self.root, self.files[0]), 0o777),
mock.call(os.path.join(self.root, self.files[1]), 0o777)])
@mock.patch('os.walk')
@mock.patch('os.chmod')
def test_ChangePermissions_DefaultValues_ReturnsTrueOnSuccess(self, mock_chmod, mock_walk):
os.walk.return_value = self.walk_iter
self.assertEqual(file_system.change_permissions('.', 0o777), True)
@mock.patch('os.walk')
@mock.patch('os.chmod')
def test_ChangePermissions_OSErrorRaised_ReturnsFalse(self, mock_chmod, mock_walk):
os.walk.return_value = self.walk_iter
os.chmod.side_effect = OSError()
self.assertEqual(file_system.change_permissions('.', 0o777), False)
class TestUnlockFile(unittest.TestCase):
def setUp(self):
self.file_name = 'file'
@mock.patch('os.chmod')
@mock.patch('os.access')
def test_UnlockFile_WriteLocked_UnlockFile(self, mock_access, mock_chmod):
mock_access.return_value = False
success = file_system.unlock_file(self.file_name)
self.assertTrue(success)
@mock.patch('os.chmod')
@mock.patch('os.access')
def test_UnlockFile_AlreadyUnlocked_LogAlreadyUnlocked(self, mock_access, mock_chmod):
mock_access.return_value = True
success = file_system.unlock_file(self.file_name)
self.assertFalse(success)
class TestLockFile(unittest.TestCase):
def setUp(self):
self.file_name = 'file'
@mock.patch('os.chmod')
@mock.patch('os.access')
def test_UnlockFile_UnlockedFile_FileLockedSuccessReturnsTrue(self, mock_access, mock_chmod):
mock_access.return_value = True
success = file_system.lock_file(self.file_name)
self.assertTrue(success)
@mock.patch('os.chmod')
@mock.patch('os.access')
def test_UnlockFile_AlreadyLocked_FileLockedFailedReturnsFalse(self, mock_access, mock_chmod):
mock_access.return_value = False
success = file_system.lock_file(self.file_name)
self.assertFalse(success)
class TestRemoveSymlinks(unittest.TestCase):
def setUp(self):
# Create a mock of a os.walk return iterable.
self.root = 'root'
self.dirs = ['dir1', 'dir2']
self.files = ['file1', 'file2']
self.walk_iter = iter([(self.root, self.dirs, self.files)])
@mock.patch('os.walk')
@mock.patch('os.rmdir')
def test_RemoveSymlinks_DefaultValues_RmdirCalledCorrectly(self, mock_rmdir, mock_walk):
os.walk.return_value = self.walk_iter
file_system.remove_symlinks('.')
self.assertEqual(mock_rmdir.mock_calls, [mock.call(os.path.join(self.root, self.dirs[0])),
mock.call(os.path.join(self.root, self.dirs[1]))])
@mock.patch('os.walk')
@mock.patch('os.rmdir')
def test_RemoveSymlinks_OSErrnoEEXISTRaised_RaiseOSError(self, mock_rmdir, mock_walk):
os.walk.return_value = self.walk_iter
error = OSError()
error.errno = errno.EEXIST
mock_rmdir.side_effect = error
with self.assertRaises(OSError):
file_system.remove_symlinks('.')
class TestDelete(unittest.TestCase):
def setUp(self):
self.path_list = ['file1', 'file2', 'dir1', 'dir2']
def tearDown(self):
self.path_list = None
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
@mock.patch('ly_test_tools.environment.file_system.change_permissions', mock.MagicMock())
@mock.patch('shutil.rmtree', mock.MagicMock())
@mock.patch('os.remove', mock.MagicMock())
def test_Delete_StringArg_ConvertsToList(self, mock_isfile, mock_isdir):
mock_file_str = 'foo'
mock_isdir.return_value = False
mock_isfile.return_value = False
file_system.delete(mock_file_str, del_files=True, del_dirs=True)
mock_isfile.assert_called_once_with(mock_file_str)
mock_isdir.assert_called_once_with(mock_file_str)
@mock.patch('ly_test_tools.environment.file_system.change_permissions')
@mock.patch('shutil.rmtree')
@mock.patch('os.remove')
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
def test_ChangePermissions_OSErrorRaised_ReturnsZero(self, mock_isfile, mock_isdir, mock_remove, mock_rmtree, mock_chper):
mock_rmtree.side_effect = OSError()
self.assertEqual(file_system.delete(self.path_list, del_files=True, del_dirs=True), False)
@mock.patch('ly_test_tools.environment.file_system.change_permissions')
@mock.patch('shutil.rmtree')
@mock.patch('os.remove')
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
def test_ChangePermissions_DefaultValues_ReturnsLenOfList(self,
mock_isfile, mock_isdir, mock_remove, mock_rmtree, mock_chper):
self.assertEqual(file_system.delete(self.path_list, del_files=True, del_dirs=True), True)
@mock.patch('os.chmod')
@mock.patch('ly_test_tools.environment.file_system.change_permissions')
@mock.patch('shutil.rmtree')
@mock.patch('os.remove')
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
def test_ChangePermissions_DirsFalse_RMTreeNotCalled(self,
mock_isfile, mock_isdir, mock_remove, mock_rmtree, mock_chper,
mock_chmod):
file_system.delete(self.path_list, del_files=True, del_dirs=False)
self.assertEqual(mock_rmtree.called, False)
self.assertEqual(mock_chmod.called, True)
self.assertEqual(mock_remove.called, True)
@mock.patch('ly_test_tools.environment.file_system.change_permissions')
@mock.patch('shutil.rmtree')
@mock.patch('os.remove')
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
def test_ChangePermissions_NoDirs_RMTreeNotCalled(self,
mock_isfile, mock_isdir, mock_remove, mock_rmtree, mock_chper):
mock_isdir.return_value = False
file_system.delete(self.path_list, del_files=False, del_dirs=True)
self.assertEqual(mock_rmtree.called, False)
@mock.patch('ly_test_tools.environment.file_system.change_permissions')
@mock.patch('shutil.rmtree')
@mock.patch('os.remove')
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
def test_ChangePermissions_FilesFalse_RemoveNotCalled(self,
mock_isfile, mock_isdir, mock_remove, mock_rmtree, mock_chper):
file_system.delete(self.path_list, del_files=False, del_dirs=True)
self.assertEqual(mock_rmtree.called, True)
self.assertEqual(mock_remove.called, False)
@mock.patch('ly_test_tools.environment.file_system.change_permissions')
@mock.patch('shutil.rmtree')
@mock.patch('os.remove')
@mock.patch('os.path.isdir')
@mock.patch('os.path.isfile')
def test_ChangePermissions_NoFiles_RemoveNotCalled(self,
mock_isfile, mock_isdir, mock_remove, mock_rmtree, mock_chper):
mock_isfile.return_value = False
file_system.delete(self.path_list, del_files=True, del_dirs=False)
self.assertEqual(mock_remove.called, False)
class TestDeleteOldest(unittest.TestCase):
def setUp(self):
self.path_list = ['file1', 'file2', 'dir1', 'dir2']
self.age_list = [4, 3, 2, 1]
def tearDown(self):
self.path_list = None
self.age_list = None
@mock.patch('glob.iglob')
@mock.patch('os.path.getctime')
@mock.patch('ly_test_tools.environment.file_system.delete')
def test_DeleteOldest_DefaultValuesKeepAll_DeleteCalledWithEmptyList(self, mock_delete, mock_ctime, mock_glob):
mock_glob.return_value = self.path_list
mock_ctime.side_effect = self.age_list
# Nothing will be deleted because it's keeping everything.
file_system.delete_oldest('', len(self.path_list), del_files=True, del_dirs=False)
mock_delete.assert_called_once_with([], True, False)
@mock.patch('glob.iglob')
@mock.patch('os.path.getctime')
@mock.patch('ly_test_tools.environment.file_system.delete')
def test_DeleteOldest_DefaultValuesKeepNone_DeleteCalledWithList(self, mock_delete, mock_ctime, mock_glob):
mock_glob.return_value = self.path_list
mock_ctime.side_effect = self.age_list
# Everything will be deleted because it's keeping nothing.
file_system.delete_oldest('', 0, del_files=True, del_dirs=False)
mock_delete.assert_called_once_with(self.path_list, True, False)
@mock.patch('glob.iglob')
@mock.patch('os.path.getctime')
@mock.patch('ly_test_tools.environment.file_system.delete')
def test_DeleteOldest_DefaultValuesKeepOne_DeleteCalledWithoutNewest(self, mock_delete, mock_ctime, mock_glob):
mock_glob.return_value = self.path_list
mock_ctime.side_effect = self.age_list
file_system.delete_oldest('', 1, del_files=True, del_dirs=False)
self.path_list.pop(0)
mock_delete.assert_called_once_with(self.path_list, True, False)
@mock.patch('glob.iglob')
@mock.patch('os.path.getctime')
@mock.patch('ly_test_tools.environment.file_system.delete')
def test_DeleteOldest_UnsortedListDeleteOldest_DeleteCalledWithOldest(self, mock_delete, mock_ctime, mock_glob):
mock_glob.return_value = ['newest', 'old', 'newer']
mock_ctime.side_effect = [100, 0, 50]
file_system.delete_oldest('', 2, del_files=True, del_dirs=False)
mock_delete.assert_called_once_with(['old'], True, False)
class TestMakeJunction(unittest.TestCase):
def test_MakeJunction_Nondir_RaisesIOError(self):
with self.assertRaises(IOError):
file_system.make_junction('', '')
@mock.patch('os.path.isdir')
@mock.patch('sys.platform', 'linux2')
def test_MakeJunction_NoSupportPlatform_RaisesIOError(self, mock_isdir):
mock_isdir.return_value = True
with self.assertRaises(IOError):
file_system.make_junction('', '')
@mock.patch('subprocess.check_call')
@mock.patch('os.path.isdir')
@mock.patch('sys.platform', 'win32')
def test_MakeJunction_Win32_SubprocessFails_RaiseSubprocessError(self, mock_isdir, mock_sub_call):
mock_isdir.return_value = True
mock_sub_call.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
with self.assertRaises(subprocess.CalledProcessError):
file_system.make_junction('', '')
@mock.patch('subprocess.check_output')
@mock.patch('os.path.isdir')
@mock.patch('sys.platform', 'darwin')
def test_MakeJunction_Darwin_SubprocessFails_RaiseSubprocessError(self, mock_isdir, mock_sub_call):
mock_isdir.return_value = True
mock_sub_call.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
with self.assertRaises(subprocess.CalledProcessError):
file_system.make_junction('', '')
@mock.patch('subprocess.check_output')
@mock.patch('os.path.isdir')
@mock.patch('sys.platform', 'win32')
def test_MakeJunction_Win32_SubprocessCall_Calls(self, mock_isdir, mock_sub_call):
mock_isdir.return_value = True
src = 'source'
dest = 'destination'
file_system.make_junction(dest, src)
mock_sub_call.assert_called_once_with(['mklink', '/J', dest, src], shell=True)
@mock.patch('subprocess.check_output')
@mock.patch('os.path.isdir')
@mock.patch('sys.platform', 'darwin')
def test_MakeJunction_Darwin_SubprocessCall_Calls(self, mock_isdir, mock_sub_call):
mock_isdir.return_value = True
src = 'source'
dest = 'destination'
file_system.make_junction(dest, src)
mock_sub_call.assert_called_once_with(['ln', dest, src])
class TestFileBackup(unittest.TestCase):
def setUp(self):
self._dummy_dir = os.path.join('somewhere', 'something')
self._dummy_file = 'dummy.txt'
self._dummy_backup_file = os.path.join(self._dummy_dir, '{}.bak'.format(self._dummy_file))
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_BackupSettings_SourceExists_BackupCreated(self, mock_path_isdir, mock_backup_exists, mock_copy):
mock_path_isdir.return_value = True
mock_backup_exists.side_effect = [True, False]
file_system.create_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_called_with(self._dummy_file, self._dummy_backup_file)
@mock.patch('ly_test_tools.environment.file_system.logger.warning')
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_BackupSettings_BackupExists_WarningLogged(self, mock_path_isdir, mock_backup_exists, mock_copy, mock_logger_warning):
mock_path_isdir.return_value = True
mock_backup_exists.return_value = True
file_system.create_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_called_with(self._dummy_file, self._dummy_backup_file)
mock_logger_warning.assert_called_once()
@mock.patch('ly_test_tools.environment.file_system.logger.warning')
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_BackupSettings_SourceNotExists_WarningLogged(self, mock_path_isdir, mock_backup_exists, mock_copy, mock_logger_warning):
mock_path_isdir.return_value = True
mock_backup_exists.return_value = False
file_system.create_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_not_called()
mock_logger_warning.assert_called_once()
@mock.patch('ly_test_tools.environment.file_system.logger.warning')
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_BackupSettings_CannotCopy_WarningLogged(self, mock_path_isdir, mock_backup_exists, mock_copy, mock_logger_warning):
mock_path_isdir.return_value = True
mock_backup_exists.side_effect = [True, False]
mock_copy.side_effect = Exception('some error')
file_system.create_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_called_with(self._dummy_file, self._dummy_backup_file)
mock_logger_warning.assert_called_once()
@mock.patch('ly_test_tools.environment.file_system.logger.error')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_BackupSettings_InvalidDir_ErrorLogged(self, mock_path_isdir, mock_backup_exists, mock_logger_error):
mock_path_isdir.return_value = False
mock_backup_exists.return_value = False
file_system.create_backup(self._dummy_file, None)
mock_logger_error.assert_called_once()
class TestFileBackupRestore(unittest.TestCase):
def setUp(self):
self._dummy_dir = os.path.join('somewhere', 'something')
self._dummy_file = 'dummy.txt'
self._dummy_backup_file = os.path.join(self._dummy_dir, '{}.bak'.format(self._dummy_file))
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_RestoreSettings_BackupRestore_Success(self, mock_path_isdir, mock_exists, mock_copy):
mock_path_isdir.return_value = True
mock_exists.return_value = True
file_system.restore_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_called_with(self._dummy_backup_file, self._dummy_file)
@mock.patch('ly_test_tools.environment.file_system.logger.warning')
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_RestoreSettings_CannotCopy_WarningLogged(self, mock_path_isdir, mock_exists, mock_copy, mock_logger_warning):
mock_path_isdir.return_value = True
mock_exists.return_value = True
mock_copy.side_effect = Exception('some error')
file_system.restore_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_called_with(self._dummy_backup_file, self._dummy_file)
mock_logger_warning.assert_called_once()
@mock.patch('ly_test_tools.environment.file_system.logger.warning')
@mock.patch('shutil.copy')
@mock.patch('os.path.exists')
@mock.patch('os.path.isdir')
def test_RestoreSettings_BackupNotExists_WarningLogged(self, mock_path_isdir, mock_exists, mock_copy, mock_logger_warning):
mock_path_isdir.return_value = True
mock_exists.return_value = False
file_system.restore_backup(self._dummy_file, self._dummy_dir)
mock_copy.assert_not_called()
mock_logger_warning.assert_called_once()
@mock.patch('ly_test_tools.environment.file_system.logger.error')
def test_RestoreSettings_InvalidDir_ErrorLogged(self, mock_logger_error):
file_system.restore_backup(self._dummy_file, None)
mock_logger_error.assert_called_once()
@mock.patch('ly_test_tools.environment.file_system.logger.error')
@mock.patch('os.path.isdir')
def test_RestoreSettings_InvalidDir_ErrorLogged(self, mock_path_isdir, mock_logger_error):
mock_path_isdir.return_value = False
file_system.restore_backup(self._dummy_file, self._dummy_dir)
mock_logger_error.assert_called_once()
class TestReduceFileName(unittest.TestCase):
def test_Reduce_LongString_ReturnsReducedString(self):
target_name = 'really_long_string_that_needs_reduction' # len(mock_file_name) == 39
max_length = 25
under_test = file_system.reduce_file_name_length(target_name, max_length)
assert len(under_test) == max_length
def test_Reduce_ShortString_ReturnsSameString(self):
target_name = 'less_than_max' # len(mock_file_name) == 13
max_length = 25
under_test = file_system.reduce_file_name_length(target_name, max_length)
assert under_test == target_name
def test_Reduce_NoString_RaisesTypeError(self):
with pytest.raises(TypeError):
file_system.reduce_file_name_length(max_length=25)
def test_Reduce_NoMaxLength_RaisesTypeError(self):
target_name = 'raises_type_error'
with pytest.raises(TypeError):
file_system.reduce_file_name_length(file_name=target_name)
@@ -0,0 +1,375 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.pytest_plugin.test_tools_fixtures
"""
import datetime
import os
import unittest.mock as mock
import pytest
import ly_test_tools._internal.pytest_plugin.test_tools_fixtures as test_tools_fixtures
pytestmark = pytest.mark.SUITE_smoke
class TestFixtures(object):
def test_AddOptionForLogs_MockParser_OptionAdded(self):
mock_parser = mock.MagicMock()
test_tools_fixtures.pytest_addoption(mock_parser)
mock_call = mock_parser.addoption.mock_calls[0]
"""
mock_call is the same as: call('--output_path', help='Set the folder name for the output logs.')
which results in a 3-tuple with (name, args, kwargs), so --output_path is in the list of args
"""
assert mock_call[1][0] == '--output-path'
def test_RecordSuiteProperty_MockRequestWithEmptyXml_PropertyAdded(self):
mock_request = mock.MagicMock()
mock_xml = mock.MagicMock()
mock_request.config._xml = mock_xml
func = test_tools_fixtures._record_suite_property(mock_request)
func('NewProperty', 'value')
mock_xml.add_global_property.assert_called_once_with('NewProperty', 'value')
def test_RecordSuiteProperty_MockRequestWithPropertyXml_PropertyUpdated(self):
mock_request = mock.MagicMock()
mock_xml = mock.MagicMock()
mock_xml.global_properties = [('ExistingProperty', 'old value')]
mock_request.config._xml = mock_xml
func = test_tools_fixtures._record_suite_property(mock_request)
func('ExistingProperty', 'new value')
assert mock_xml.global_properties[0] == ('ExistingProperty', 'new value')
mock_xml.add_global_property.assert_not_called()
@mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.logger')
def test_RecordSuiteProperty_MockRequestWithoutXml_NoOpReturned(self, mock_logger):
mock_request = mock.MagicMock()
mock_request.config._xml = None
func = test_tools_fixtures._record_suite_property(mock_request)
mock_logger.debug.assert_not_called()
func('SomeProperty', 'some value')
mock_logger.debug.assert_called_once()
@mock.patch('os.makedirs')
def test_LogsPath_CustomPathOption_CustomPathCreated(self, mock_makedirs):
expected = 'SomePath'
mock_config = mock.MagicMock()
mock_config.getoption.return_value = expected
actual = test_tools_fixtures._get_output_path(mock_config)
assert actual == expected
mock_makedirs.assert_called_once_with('SomePath', exist_ok=True)
@mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.datetime',
mock.Mock(now=lambda: datetime.datetime(2019, 10, 11)))
@mock.patch('os.getcwd')
@mock.patch('os.makedirs')
def test_LogsPath_NoPathOption_DefaultPathCreated(self, mock_makedirs, mock_getcwd):
mock_config = mock.MagicMock()
mock_cwd = 'C:/foo'
mock_getcwd.return_value = mock_cwd
mock_config.getoption.return_value = None
expected = os.path.join(mock_cwd,
'TestResults',
'2019-10-11T00-00-00-000000',
'pytest_results')
actual = test_tools_fixtures._get_output_path(mock_config)
assert actual == expected
mock_makedirs.assert_called_once_with(expected, exist_ok=True)
def test_RecordBuildName_MockRecordFunction_MockFunctionCalled(self):
mock_fn = mock.MagicMock()
func = test_tools_fixtures._record_build_name(mock_fn)
func('MyBuild')
mock_fn.assert_called_once_with('build', 'MyBuild')
@mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.datetime',
mock.Mock(now=lambda: datetime.datetime(2019, 10, 11)))
def test_RecordTimeStamp_MockRecordFunction_MockFunctionCalled(self):
mock_fn = mock.MagicMock()
test_tools_fixtures._record_test_timestamp(mock_fn)
mock_fn.assert_called_once_with('timestamp', '2019-10-11T00-00-00-000000')
@mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.datetime',
mock.Mock(now=lambda: datetime.datetime(2019, 10, 11)))
@mock.patch('socket.gethostname')
@mock.patch('getpass.getuser')
def test_RecordSuiteData_MockRecordFunction_MockFunctionCalled(self, mock_getuser, mock_gethostname):
mock_getuser.return_value = 'foo@bar.baz'
mock_gethostname.return_value = 'bar.baz'
mock_fn = mock.MagicMock()
test_tools_fixtures._record_suite_data(mock_fn)
expected_calls = [mock.call('timestamp', '2019-10-11T00-00-00-000000'),
mock.call('hostname', 'bar.baz'),
mock.call('username', 'foo@bar.baz')]
mock_fn.assert_has_calls(expected_calls)
@mock.patch("ly_test_tools._internal.log.py_logging_util.initialize_logging", mock.MagicMock())
@mock.patch("ly_test_tools.builtin.helpers.setup_builtin_workspace")
@mock.patch("ly_test_tools.builtin.helpers.create_builtin_workspace")
def test_Workspace_MockFixturesAndExecTeardown_ReturnWorkspaceRegisterTeardown(self, mock_create, mock_setup):
test_module = 'example.tests.test_system_example'
test_class = ('TestSystemExample.test_SystemTestExample_AllSupportedPlatforms_LaunchSamplesProject'
'[120-simple_jacklocomotion-SamplesProject-all-profile-win_x64_vs2017]')
test_method = 'test_SystemTestExample_AllSupportedPlatforms_LaunchSamplesProject'
artifact_folder_name = 'TheArtifactFolder'
artifact_path = "PathToArtifacts"
mock_request = mock.MagicMock()
mock_request.addfinalizer = mock.MagicMock()
mock_request.node.module.__name__ = test_module
mock_request.node.getmodpath.return_value = test_class
mock_request.node.originalname = test_method
mock_workspace = mock.MagicMock()
mock_workspace.artifact_manager.generate_folder_name.return_value = artifact_folder_name
mock_workspace.artifact_manager.artifact_path = artifact_path
mock_workspace.artifact_manager.gather_artifacts.return_value = os.path.join(artifact_path, 'foo.zip')
mock_create.return_value = mock_workspace
mock_property = mock.MagicMock()
mock_build_name = mock.MagicMock()
mock_logs = "foo"
under_test = test_tools_fixtures._workspace(
request=mock_request,
build_directory='foo',
project="",
record_property=mock_property,
record_build_name=mock_build_name,
output_path=mock_logs,
asset_processor_platform='ap_platform'
)
assert under_test is mock_workspace
# verify additional commands are called
mock_create.assert_called_once()
mock_setup.assert_called_once_with(under_test, artifact_folder_name, mock_request.session.testscollected)
# verify teardown was hooked but not called
mock_request.addfinalizer.assert_called_once()
mock_workspace.teardown.assert_not_called()
# execute teardown hook from recorded call, and verify called
mock_request.addfinalizer.call_args[0][0]()
mock_workspace.teardown.assert_called_once()
mock_workspace.artifact_manager.generate_folder_name.assert_called_with(
test_module.split('.')[-1], # 'example.tests.test_system_example' -> 'test_system_example'
test_class.split('.')[0], # 'TestSystemExample.test_SystemTestExample_...' -> 'TestSystemExample'
test_method # 'test_SystemTestExample_AllSupportedPlatforms_LaunchSamplesProject'
)
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
@mock.patch("ly_test_tools.launchers.launcher_helper.create_launcher")
def test_Launcher_MockHelper_Passthrough(self, mock_create):
retval = mock.MagicMock()
mock_create.return_value = retval
mock_workspace = mock.MagicMock()
mock_workspace.paths.waf.return_value = "dummy"
mock_workspace.paths.autoexec_file.return_value = "dummy2"
file_handler = mock.mock_open()
with mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.open', file_handler, create=True):
with open(mock_workspace.paths.autoexec_file(), 'w') as autoexec_file:
autoexec_file.write('map ' + 'level')
under_test = test_tools_fixtures._launcher(mock.MagicMock(), mock_workspace, 'windows', 'level')
mock_create.assert_called_once()
assert retval is under_test
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
@mock.patch("ly_test_tools.launchers.launcher_helper.create_launcher")
def test_Launcher_MockHelper_TeardownCalled(self, mock_create):
retval = mock.MagicMock()
retval.stop = mock.MagicMock()
mock_create.return_value = retval
mock_request = mock.MagicMock()
mock_workspace = mock.MagicMock()
mock_workspace.paths.waf.return_value = "dummy"
mock_workspace.paths.autoexec_file.return_value = "dummy2"
file_handler = mock.mock_open()
def _fail_finalizer():
assert False, "teardown should have been added to finalizer"
def _capture_finalizer(func):
nonlocal _finalizer
_finalizer = func
_finalizer = _fail_finalizer
mock_request.addfinalizer = _capture_finalizer
with mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.open', file_handler, create=True):
with open(mock_workspace.paths.autoexec_file(), 'w') as autoexec_file:
autoexec_file.write('map ' + 'level')
under_test = test_tools_fixtures._launcher(mock_request, mock_workspace, 'windows', 'level')
assert retval is under_test
assert _finalizer is not None
_finalizer()
retval.stop.assert_called_once()
@mock.patch("ly_test_tools.launchers.launcher_helper.create_dedicated_launcher")
def test_DedicatedLauncher_MockHelper_Passthrough(self, mock_create):
retval = mock.MagicMock()
mock_create.return_value = retval
under_test = test_tools_fixtures._dedicated_launcher(mock.MagicMock(), mock.MagicMock(), 'windows')
mock_create.assert_called_once()
assert retval is under_test
@mock.patch("ly_test_tools.launchers.launcher_helper.create_dedicated_launcher")
def test_DedicatedLauncher_MockHelper_TeardownCalled(self, mock_create):
retval = mock.MagicMock()
retval.stop = mock.MagicMock()
mock_request = mock.MagicMock()
mock_create.return_value = retval
def _fail_finalizer():
assert False, "teardown should have been added to finalizer"
def _capture_finalizer(func):
nonlocal _finalizer
_finalizer = func
_finalizer = _fail_finalizer
mock_request.addfinalizer = _capture_finalizer
under_test = test_tools_fixtures._dedicated_launcher(mock_request, mock.MagicMock(), 'windows')
mock_create.assert_called_once()
assert retval is under_test
assert _finalizer is not None
_finalizer()
retval.stop.assert_called_once()
@mock.patch("ly_test_tools.launchers.launcher_helper.create_editor")
def test_Editor_MockHelper_Passthrough(self, mock_create):
retval = mock.MagicMock()
mock_create.return_value = retval
under_test = test_tools_fixtures._editor(mock.MagicMock(), mock.MagicMock(), 'windows_editor')
mock_create.assert_called_once()
assert retval is under_test
@mock.patch("ly_test_tools.launchers.launcher_helper.create_editor")
def test_Editor_MockHelper_TeardownCalled(self, mock_create):
retval = mock.MagicMock()
retval.stop = mock.MagicMock()
mock_request = mock.MagicMock()
mock_create.return_value = retval
def _fail_finalizer():
assert False, "teardown should have been added to finalizer"
def _capture_finalizer(func):
nonlocal _finalizer
_finalizer = func
_finalizer = _fail_finalizer
mock_request.addfinalizer = _capture_finalizer
under_test = test_tools_fixtures._editor(mock_request, mock.MagicMock(), 'windows_editor')
mock_create.assert_called_once()
assert retval is under_test
assert _finalizer is not None
_finalizer()
retval.stop.assert_called_once()
@mock.patch('ly_test_tools._internal.pytest_plugin.test_tools_fixtures.get_fixture_argument')
@mock.patch('ly_test_tools._internal.managers.ly_process_killer.detect_lumberyard_processes')
@mock.patch('ly_test_tools._internal.managers.ly_process_killer.kill_processes', mock.MagicMock())
def test_AutomaticProcessKiller_ProcessKillList_KillsDetectedProcesses(self, mock_detect_processes,
mock_get_fixture_argument):
mock_processes_list = ['foo', 'bar', 'foobar']
mock_detected_processes = ['foo', 'bar']
mock_detect_processes.return_value = mock_detected_processes
mock_get_fixture_argument.return_value = mock_processes_list
under_test = test_tools_fixtures._automatic_process_killer(mock_processes_list)
under_test.detect_lumberyard_processes.assert_called_with(processes_list=mock_processes_list)
under_test.kill_processes.assert_called_with(processes_list=mock_detected_processes)
@mock.patch('ly_test_tools.environment.watchdog.CrashLogWatchdog.start', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.CrashLogWatchdog')
def test_CrashLogWatchdog_Instantiates_CreatesWatchdog(self, under_test):
mock_workspace = mock.MagicMock()
mock_path = 'C:/foo'
mock_workspace.paths.project_log.return_value = mock_path
mock_request = mock.MagicMock()
mock_request.addfinalizer = mock.MagicMock()
mock_raise_on_crash = mock.MagicMock()
mock_watchdog = test_tools_fixtures._crash_log_watchdog(mock_request, mock_workspace, mock_raise_on_crash)
under_test.assert_called_once_with(os.path.join(mock_path, 'error.log'), raise_on_condition=mock_raise_on_crash)
@mock.patch('ly_test_tools.environment.watchdog.CrashLogWatchdog.start')
def test_CrashLogWatchdog_Instantiates_StartsThread(self, under_test):
mock_workspace = mock.MagicMock()
mock_path = 'C:/foo'
mock_workspace.paths.project_log.return_value = mock_path
mock_request = mock.MagicMock()
mock_request.addfinalizer = mock.MagicMock()
mock_raise_on_crash = mock.MagicMock()
test_tools_fixtures._crash_log_watchdog(mock_request, mock_workspace, mock_raise_on_crash)
under_test.assert_called_once()
@mock.patch('ly_test_tools.environment.watchdog.CrashLogWatchdog.start', mock.MagicMock())
def test_CrashLogWatchdog_Instantiates_AddsTeardown(self):
mock_workspace = mock.MagicMock()
mock_path = 'C:/foo'
mock_workspace.paths.project_log.return_value = mock_path
mock_request = mock.MagicMock()
mock_request.addfinalizer = mock.MagicMock()
mock_raise_on_crash = mock.MagicMock()
mock_watchdog = test_tools_fixtures._crash_log_watchdog(mock_request, mock_workspace, mock_raise_on_crash)
mock_request.addfinalizer.assert_called_once()
@mock.patch('ly_test_tools.environment.watchdog.CrashLogWatchdog.start', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.CrashLogWatchdog.stop')
def test_CrashLogWatchdog_Teardown_CallsStop(self, mock_stop):
mock_workspace = mock.MagicMock()
mock_path = 'C:/foo'
mock_workspace.paths.project_log.return_value = mock_path
mock_request = mock.MagicMock()
mock_request.addfinalizer = mock.MagicMock()
mock_raise_condition = mock.MagicMock()
mock_watchdog = test_tools_fixtures._crash_log_watchdog(mock_request, mock_workspace, mock_raise_condition)
mock_request.addfinalizer.call_args[0][0]()
mock_stop.assert_called_once()
@@ -0,0 +1,38 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for image_capture.py
"""
import unittest.mock as mock
import unittest
import pytest
import ly_test_tools.image.image_capture
pytestmark = pytest.mark.SUITE_smoke
class TestScreenCap(unittest.TestCase):
@mock.patch('pyscreenshot.grab')
def test_ScreenCap_CoordsAndNameGiven_Used(self, mock_grab):
mock_grab.return_value = mock.MagicMock()
mock_grab.return_value.save = mock.MagicMock()
x1 = 10
x2 = 20
y1 = 30
y2 = 40
image_name = 'test_capture.png'
ly_test_tools.image.image_capture.screencap(x1, y1, x2, y2, filename=image_name)
mock_grab.assert_called_once_with(bbox=(x1, y1, x2, y2), childprocess=False)
mock_grab.return_value.save.assert_called_once_with(image_name)
@@ -0,0 +1,331 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for android launcher-wrappers: all are sanity code-path tests, since no interprocess actions should be taken
"""
import unittest.mock as mock
import pytest
import ly_test_tools.launchers
import ly_test_tools.launchers.platforms.android.launcher
import ly_test_tools.launchers.exceptions
import ly_test_tools.mobile.android
pytestmark = pytest.mark.SUITE_smoke
VALID_ANDROID_CONFIG = {
'android': {
'id': '000000000000000'
}
}
PACKAGE_NAME = "dummy_project_path"
class MockedWorkspace(object):
def __init__(self):
self.paths = mock.MagicMock()
self.setup_assistant = mock.MagicMock()
self.asset_processor = mock.MagicMock()
self.shader_compiler = mock.MagicMock()
self.settings = mock.MagicMock()
self.paths.dev.return_value = 'dev_path'
self.paths.build_directory.return_value = 'build_directory'
self.paths.autoexec_file.return_value = 'autoexec.cfg'
self.project = 'project_name'
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.open', mock.mock_open())
class TestLauncherModule:
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.json.loads')
def test_GetPackageName_MockJsonHasKey_ReturnsValue(self, mock_json):
dummy_name = "some_name"
mock_json.return_value = {'android_settings': {'package_name': dummy_name}}
under_test = ly_test_tools.launchers.platforms.android.launcher.get_package_name(PACKAGE_NAME)
assert under_test == "some_name"
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.json.loads')
def test_GetPackageName_MockJsonMissingKey_SetupError(self, mock_json):
mock_json.return_value = {"one": "two"}
with pytest.raises(ly_test_tools.launchers.exceptions.SetupError):
ly_test_tools.launchers.platforms.android.launcher.get_package_name(PACKAGE_NAME)
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_GetPid_EarlyVersionAndExplodes_NoPid(self, mock_output):
mock_output.side_effect = ['23', Exception]
under_test = ly_test_tools.launchers.platforms.android.launcher.get_pid("dummy", ["dummy"])
assert mock_output.call_count == 2
assert not under_test
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_GetPid_LaterVersionAndExplodes_NoPid(self, mock_output):
mock_output.side_effect = ['25', Exception]
under_test = ly_test_tools.launchers.platforms.android.launcher.get_pid("dummy", ["dummy"])
assert mock_output.call_count == 2
assert not under_test
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_GetPid_PidHasSingleValue_ReturnPid(self, mock_output):
mock_output.side_effect = ['25', '12']
under_test = ly_test_tools.launchers.platforms.android.launcher.get_pid("dummy", ["dummy"])
assert mock_output.call_count == 2
assert under_test == '12'
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_GetPid_PidHasMultipleValues_ReturnPid(self, mock_output):
mock_output.side_effect = ['25', 'value 12 found']
under_test = ly_test_tools.launchers.platforms.android.launcher.get_pid("dummy", ["dummy"])
assert mock_output.call_count == 2
assert under_test == '12'
def test_GenerateMapCommand_HasMapArg_ReturnsMapCommand(self):
args_list = ['random_arg', '+map', 'map_name', 'another_arg']
under_test = ly_test_tools.launchers.platforms.android.launcher.generate_android_map_command(args_list)
assert under_test == 'map map_name'
def test_GenerateMapCommand_NoMapArg_ReturnsEmptyString(self):
args_list = ['random_arg', 'stuff', 'map_name', 'another_arg']
under_test = ly_test_tools.launchers.platforms.android.launcher.generate_android_map_command(args_list)
assert under_test == ''
@mock.patch('os.listdir', mock.MagicMock())
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.get_package_name',
mock.MagicMock(return_value=PACKAGE_NAME))
@mock.patch('ly_test_tools.environment.file_system.create_backup', mock.MagicMock)
@mock.patch('ly_test_tools.environment.file_system.restore_backup', mock.MagicMock)
@mock.patch('ly_test_tools.mobile.android.check_adb_connection_state', mock.MagicMock)
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.open', mock.mock_open())
class TestAndroidLauncher:
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
def test_ReadDeviceConfigINI_ReturnDeviceID_DeviceIDSet(self, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
assert launcher._device_id == VALID_ANDROID_CONFIG['android']['id']
assert ['adb', '-s', launcher._device_id] == launcher._adb_prefix_command
mock_config.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
def test_ReadDeviceConfigINI_ReturnsInvalidConfig_SetupError(self, mock_config):
mock_config.return_value = {'device': {'id': 12345}}
mock_workspace = MockedWorkspace()
with pytest.raises(ly_test_tools.launchers.exceptions.SetupError):
ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.mobile.android.forward_tcp')
@mock.patch('ly_test_tools.mobile.android.reverse_tcp')
@mock.patch('ly_test_tools.mobile.android.undo_tcp_port_changes')
def test_EnableAndroidCaps_SetsAndroidCaps_CallsReverseTCPForwardTCP(self, mock_undo_tcp, mock_reverse_tcp,
mock_forward_tcp, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
under_test = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
under_test._enable_android_capabilities()
assert mock_reverse_tcp.call_count == 2
mock_forward_tcp.assert_called_once()
mock_undo_tcp.assert_called_with(VALID_ANDROID_CONFIG['android']['id'])
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher.backup_settings')
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher.configure_settings')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher._enable_android_capabilities')
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher._is_valid_android_environment')
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock(return_value=True))
def test_Setup_ValidSetup_SetupCallsSucceed(self, mock_valid_env, mock_enable_caps, mock_config,
mock_configure_settings, mock_backup):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
mock_project_log_path = 'c:/mock_project/log/'
mock_workspace.paths.project_log.return_value = mock_project_log_path
under_test = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
under_test.setup()
mock_enable_caps.assert_called_once()
mock_valid_env.assert_called_once()
mock_backup.assert_called_once()
mock_configure_settings.assert_called_once()
mock_workspace.shader_compiler.start.assert_called_once()
@mock.patch('ly_test_tools.mobile.android.can_run_android')
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher.backup_settings', mock.MagicMock())
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher.configure_settings',
mock.MagicMock())
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher._enable_android_capabilities',
mock.MagicMock())
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock(return_value=True))
def test_Setup_InvalidSetupNoADB_RaisesNotImplementedErrorException(self, mock_config, mock_can_run_android):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
mock_can_run_android.return_value = False
under_test = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
with pytest.raises(NotImplementedError):
under_test.setup()
@mock.patch('ly_test_tools.mobile.android.get_devices')
@mock.patch('ly_test_tools.mobile.android.can_run_android', mock.MagicMock(return_value=True))
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher.backup_settings', mock.MagicMock())
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher.configure_settings',
mock.MagicMock())
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher._enable_android_capabilities',
mock.MagicMock())
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock(return_value=True))
def test_Setup_InvalidSetupNoDeviceConnected_RaisesSetupErrorException(self, mock_config, mock_get_devices):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
mock_get_devices.return_value = []
under_test = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
with pytest.raises(ly_test_tools.launchers.exceptions.SetupError):
under_test.setup()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.restore_settings')
@mock.patch('ly_test_tools.mobile.android.undo_tcp_port_changes', mock.MagicMock)
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.AndroidLauncher._enable_android_capabilities',
mock.MagicMock)
def test_Teardown_ValidTeardown_TeardownSucceeds(self, mock_restore, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
launcher.teardown()
mock_restore.assert_called_once()
mock_workspace.shader_compiler.stop.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.lumberyard.settings.LySettings.modify_bootstrap_setting', mock.MagicMock)
@mock.patch('ly_test_tools.lumberyard.settings.LySettings.modify_platform_setting', mock.MagicMock)
def test_ConfigureSettings_DefaultValues_SetsValues(self, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
launcher.configure_settings()
assert mock_workspace.settings.modify_bootstrap_setting.call_count == 6
assert mock_workspace.settings.modify_platform_setting.call_count == 8
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.environment.process_utils.check_output')
@mock.patch('os.path.isfile', mock.MagicMock(return_value=False))
def test_Launch_HappyPathNoAutoexec_CallsLaunchCmd(self, mock_call, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
launcher.launch()
mock_call.assert_called_once_with([
'adb', '-s', VALID_ANDROID_CONFIG['android']['id'], 'shell', 'monkey', '-p', PACKAGE_NAME,
'-c', 'android.intent.category.LAUNCHER', '1'])
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.environment.process_utils.check_output')
@mock.patch('ly_test_tools.mobile.android.push_files_to_device', mock.MagicMock)
@mock.patch('os.path.isfile', mock.MagicMock(return_value=True))
def test_Launch_HappyPathHasAutoExec_PushesFilesToDevice(self, mock_push_files, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
launcher.launch()
mock_push_files.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.environment.process_utils.check_output')
@mock.patch('os.path.isfile', mock.MagicMock(return_value=False))
def test_Launch_MonkeyAbortedInLaunchResult_RaisesSetupError(self, mock_call, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_call.return_value = 'Monkey Aborted'
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
with pytest.raises(ly_test_tools.launchers.exceptions.SetupError):
launcher.launch()
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.get_pid')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
def test_IsAlive_MockPidDNE_False(self, mock_config, mock_pid):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
mock_pid.return_value = ""
assert not launcher.is_alive()
@mock.patch('ly_test_tools.launchers.platforms.android.launcher.get_pid')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
def test_IsAlive_MockPidExists_True(self, mock_config, mock_pid):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
mock_pid.return_value = "1234"
assert launcher.is_alive()
@mock.patch('ly_test_tools.environment.process_utils.check_call')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
@mock.patch('ly_test_tools.mobile.android.check_adb_connection_state',
mock.MagicMock(return_value=ly_test_tools.mobile.android.SINGLE_DEVICE))
def test_Kill_HappyPath_KillCommandSuccess(self, mock_config, mock_call):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
launcher.kill()
mock_call.assert_called_once_with(
['adb', '-s', VALID_ANDROID_CONFIG['android']['id'], 'shell', 'am', 'force-stop', PACKAGE_NAME])
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher._config_ini_to_dict')
def test_BinaryPath_Called_RaisesNotImplementedError(self, mock_config):
mock_config.return_value = VALID_ANDROID_CONFIG
mock_workspace = MockedWorkspace()
launcher = ly_test_tools.launchers.AndroidLauncher(mock_workspace, ["dummy"])
with pytest.raises(NotImplementedError):
launcher.binary_path()
@@ -0,0 +1,228 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for base launcher-wrapper: all are sanity code-path tests, since no interprocess actions should be taken
"""
import os
import unittest.mock as mock
import pytest
import ly_test_tools.launchers
import ly_test_tools.launchers.launcher_helper
import ly_test_tools._internal.managers.artifact_manager
pytestmark = pytest.mark.SUITE_smoke
class TestBaseLauncher:
def test_Construct_TestDoubles_BaseLauncherCreated(self):
mock_workspace = mock.MagicMock()
mock_project_log_path = 'c:/mock_project/log/'
mock_workspace.paths.project_log.return_value = mock_project_log_path
under_test = ly_test_tools.launchers.Launcher(mock_workspace, ["some_args"])
assert isinstance(under_test, ly_test_tools.launchers.Launcher)
return under_test
def test_Construct_StringArgs_TypeError(self):
mock_workspace = mock.MagicMock()
with pytest.raises(TypeError):
ly_test_tools.launchers.Launcher(mock_workspace, "bad_args")
def test_BinaryPath_Unimplemented_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.binary_path()
def test_EnsureStopped_IsAliveUnimplemented_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.ensure_stopped()
def test_IsAlive_Unimplemented_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.is_alive()
def test_Kill_Unimplemented_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.kill()
def test_Launch_Unimplemented_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.launch()
@mock.patch('os.listdir', mock.MagicMock())
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock(return_value=True))
def test_Start_UnimplementedLauncher_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.start()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.launch')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.setup')
def test_Start_MockImplementedLauncher_SetupLaunch(self, mock_setup, mock_launch):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
launcher.start()
mock_setup.assert_called_once()
mock_launch.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.launch')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.setup')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.stop')
def test_WithStart_MockImplementedLauncher_SetupLaunchStop(self, mock_stop, mock_setup, mock_launch):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with launcher.start():
pass
mock_stop.assert_called_once()
mock_setup.assert_called_once()
mock_launch.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.setup')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.stop')
def test_WithStart_ErrorDuringStart_StopNotCalled(self, mock_stop, mock_setup):
mock_setup.side_effect = BufferError
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(BufferError):
with launcher.start():
pass
mock_stop.assert_not_called()
mock_setup.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.launch', mock.MagicMock)
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.setup', mock.MagicMock)
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.stop')
def test_WithStart_ErrorAfterStart_StopCalled(self, mock_stop):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(BufferError):
with launcher.start():
raise BufferError
mock_stop.assert_called_once()
def test_Stop_UnimplementedLauncher_NotImplementedError(self):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
with pytest.raises(NotImplementedError):
launcher.stop()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.kill')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.ensure_stopped')
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.teardown')
def test_Stop_MockImplementedLauncher_KillTeardown(self, mock_teardown, mock_ensure, mock_kill):
launcher = self.test_Construct_TestDoubles_BaseLauncherCreated()
launcher.stop()
mock_kill.assert_called_once()
mock_teardown.assert_called_once()
mock_ensure.assert_called_once()
@mock.patch('os.listdir', mock.MagicMock())
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock(return_value=True))
def test_Setup_TestDoublesNoAPOpen_StartAssetProcessor(self):
mock_workspace = mock.MagicMock()
mock_start_ap = mock.MagicMock()
mock_workspace.asset_processor.start = mock_start_ap
mock_project_log_path = 'c:/mock_project/log/'
mock_workspace.paths.project_log.return_value = mock_project_log_path
under_test = ly_test_tools.launchers.Launcher(mock_workspace, ["some_args"])
under_test.setup()
under_test.workspace.asset_processor.start.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.save_project_log_files', mock.MagicMock())
def test_Teardown_TestDoubles_StopAppAndStopAssetProcessor(self):
mock_workspace = mock.MagicMock()
mock_stop_ap = mock.MagicMock()
mock_workspace.asset_processor.stop = mock_stop_ap
under_test = ly_test_tools.launchers.Launcher(mock_workspace, ["some_args"])
under_test.teardown()
mock_stop_ap.assert_called_once()
@mock.patch('ly_test_tools.launchers.platforms.base.Launcher.save_project_log_files')
def test_Teardown_TeardownCalled_CallsSaveProjectLogFiles(self, under_test):
mock_workspace = mock.MagicMock()
mock_args = ['foo']
mock_launcher = ly_test_tools.launchers.Launcher(mock_workspace, mock_args)
mock_launcher.teardown()
under_test.assert_called_once()
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
@mock.patch('ly_test_tools._internal.managers.artifact_manager.ArtifactManager.save_artifact')
@mock.patch('os.listdir')
def test_SaveProjectLogFiles_LogFilesExist_SavesOnlyLogs(self, mock_listdir, under_test):
mock_log = 'foo.log'
mock_txt = 'foo.txt'
mock_args = ['foo']
mock_project_log_path = 'c:/mock_project/log/'
assert_path = os.path.join(mock_project_log_path, mock_log)
mock_listdir.return_value = [mock_log, mock_txt]
mock_workspace = mock.MagicMock()
mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(mock.MagicMock())
mock_workspace.artifact_manager = mock_artifact_manager
mock_workspace.paths.project_log.return_value = mock_project_log_path
mock_launcher = ly_test_tools.launchers.Launcher(mock_workspace, mock_args)
mock_launcher.save_project_log_files()
under_test.assert_called_once_with(assert_path, amount=100)
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
@mock.patch('ly_test_tools._internal.managers.artifact_manager.ArtifactManager.save_artifact')
@mock.patch('os.listdir')
def test_SaveProjectLogFiles_DmpFilesExist_SavesOnlyDmps(self, mock_listdir, under_test):
mock_dmp = 'foo.dmp'
mock_txt = 'foo.txt'
mock_args = ['foo']
mock_project_log_path = 'c:/mock_project/log/'
assert_path = os.path.join(mock_project_log_path, mock_dmp)
mock_listdir.return_value = [mock_dmp, mock_txt]
mock_workspace = mock.MagicMock()
mock_artifact_manager = ly_test_tools._internal.managers.artifact_manager.ArtifactManager(mock.MagicMock())
mock_workspace.artifact_manager = mock_artifact_manager
mock_workspace.paths.project_log.return_value = mock_project_log_path
mock_launcher = ly_test_tools.launchers.Launcher(mock_workspace, mock_args)
mock_launcher.save_project_log_files()
under_test.assert_called_once_with(assert_path, amount=100)
class TestLauncherBuilder(object):
"""
Fixture builder/helper in launchers.launcher
"""
def test_CreateLauncher_DummyWorkspace_DefaultLauncher(self):
dummy_workspace = mock.MagicMock()
launcher_platform = 'windows'
under_test = ly_test_tools.launchers.launcher_helper.create_launcher(
dummy_workspace, launcher_platform)
assert isinstance(under_test, ly_test_tools.launchers.Launcher)
def test_CreateDedicateLauncher_DummyWorkspace_DefaultLauncher(self):
dummy_workspace = mock.MagicMock()
launcher_platform = 'windows_dedicated'
under_test = ly_test_tools.launchers.launcher_helper.create_dedicated_launcher(
dummy_workspace, launcher_platform)
assert isinstance(under_test, ly_test_tools.launchers.Launcher)
def test_CreateEditor_DummyWorkspace_DefaultLauncher(self):
dummy_workspace = mock.MagicMock()
launcher_platform = 'windows_editor'
under_test = ly_test_tools.launchers.launcher_helper.create_editor(
dummy_workspace, launcher_platform)
assert isinstance(under_test, ly_test_tools.launchers.Launcher)
@@ -0,0 +1,72 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for mac launcher-wrappers: all are sanity code-path tests, since no interprocess actions should be taken
"""
import os
import pytest
import unittest.mock as mock
import ly_test_tools.launchers
pytestmark = pytest.mark.SUITE_smoke
class TestMacLauncher(object):
def test_Construct_TestDoubles_MacLauncherCreated(self):
under_test = ly_test_tools.launchers.MacLauncher(mock.MagicMock(), ["some_args"])
assert isinstance(under_test, ly_test_tools.launchers.Launcher)
assert isinstance(under_test, ly_test_tools.launchers.MacLauncher)
def test_BinaryPath_DummyPath_AddPathToApp(self):
dummy_path = "dummy_workspace_path"
dummy_project = "dummy_project"
mock_workspace = mock.MagicMock()
mock_workspace.paths.build_directory.return_value = dummy_path
mock_workspace.project = dummy_project
launcher = ly_test_tools.launchers.MacLauncher(mock_workspace, ["some_args"])
under_test = launcher.binary_path()
expected = os.path.join(f'{dummy_path}',
f"{dummy_project}.GameLauncher.app",
"Contents",
"MacOS",
f"{dummy_project}.GameLauncher")
assert under_test == expected
@mock.patch('ly_test_tools.launchers.MacLauncher.binary_path', mock.MagicMock)
@mock.patch('subprocess.Popen')
def test_Launch_DummyArgs_ArgsPassedToPopen(self, mock_subprocess):
dummy_args = ["some_args"]
launcher = ly_test_tools.launchers.MacLauncher(mock.MagicMock(), dummy_args)
launcher.launch()
mock_subprocess.assert_called_once()
name, args, kwargs = mock_subprocess.mock_calls[0]
unpacked_args = args[0] # args is a list inside a tuple
assert len(dummy_args) > 0, "accidentally removed dummy_args"
for expected_arg in dummy_args:
assert expected_arg in unpacked_args
@mock.patch('ly_test_tools.launchers.MacLauncher.is_alive')
def test_Kill_MockAliveFalse_SilentSuccess(self, mock_alive):
mock_alive.return_value = False
mock_proc = mock.MagicMock()
launcher = ly_test_tools.launchers.MacLauncher(mock.MagicMock(), ["dummy"])
launcher._proc = mock_proc
launcher.kill()
mock_proc.kill.assert_called_once()
mock_alive.assert_called_once()
@@ -0,0 +1,186 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for windows launcher-wrappers: all are sanity code-path tests, since no interprocess actions should be taken
"""
import os
import unittest.mock as mock
import pytest
import ly_test_tools.launchers
import ly_test_tools.launchers.exceptions
pytestmark = pytest.mark.SUITE_smoke
class TestWinLauncher(object):
def test_BinaryPath_DummyPath_AddPathToExe(self):
dummy_path = "dummy_workspace_path"
dummy_project = "dummy_project"
mock_workspace = mock.MagicMock()
mock_workspace.paths.build_directory.return_value = dummy_path
mock_workspace.project = dummy_project
launcher = ly_test_tools.launchers.WinLauncher(mock_workspace, ["some_args"])
under_test = launcher.binary_path()
expected = os.path.join(f'{dummy_path}',
f'{dummy_project}.GameLauncher.exe')
assert expected == under_test
@mock.patch('ly_test_tools.launchers.WinLauncher.binary_path', mock.MagicMock)
@mock.patch('subprocess.Popen')
def test_Launch_DummyArgs_ArgsPassedToPopen(self, mock_subprocess):
dummy_args = ["some_args"]
launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), dummy_args)
launcher.launch()
mock_subprocess.assert_called_once()
name, args, kwargs = mock_subprocess.mock_calls[0]
unpacked_args = args[0] # args is a list inside a tuple
assert len(dummy_args) > 0, "accidentally removed dummy_args"
for expected_arg in dummy_args:
assert expected_arg in unpacked_args
@mock.patch('ly_test_tools.launchers.WinLauncher.is_alive')
def test_Kill_MockAliveFalse_SilentSuccess(self, mock_alive):
mock_alive.return_value = False
mock_proc = mock.MagicMock()
launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
launcher._proc = mock_proc
launcher.kill()
mock_proc.kill.assert_called_once()
mock_alive.assert_called_once()
def test_IsAlive_NoProc_False(self):
launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
under_test = launcher.is_alive()
assert under_test is False
def test_IsAlive_MockProcNotReturned_True(self):
mock_proc = mock.MagicMock()
mock_proc.poll.return_value = None
launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
launcher._proc = mock_proc
under_test = launcher.is_alive()
assert under_test is True
def test_IsAlive_MockProcHasReturned_False(self):
mock_proc = mock.MagicMock()
mock_proc.poll.return_value = 0
launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
launcher._proc = mock_proc
under_test = launcher.is_alive()
assert under_test is False
def test_GetPid_HasProcess_ReturnsPid(self):
mock_pid = 11111
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
mock_proc = mock.MagicMock()
mock_proc.pid = mock_pid
mock_launcher._proc = mock_proc
assert mock_launcher.get_pid() == mock_pid
def test_GetPid_HasNoProcess_ReturnsNone(self):
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
mock_proc = None
mock_launcher._proc = mock_proc
assert mock_launcher.get_pid() is None
def test_GetReturnCode_HasProcess_CallsPoll(self):
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
mock_proc = mock.MagicMock()
mock_launcher._proc = mock_proc
mock_launcher.get_returncode()
mock_proc.poll.assert_called_once()
@mock.patch('subprocess.Popen.poll')
def test_GetReturnCode_HasNoProcess_ReturnsNone(self, under_test):
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
mock_launcher._proc = None
mock_launcher.get_returncode()
under_test.assert_not_called()
@mock.patch('ly_test_tools.launchers.WinLauncher.get_returncode')
def test_CheckReturnCode_Called_CallsGetReturncode(self, under_test):
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
under_test.return_value = 0
mock_launcher.check_returncode()
under_test.assert_called()
@mock.patch('ly_test_tools.launchers.WinLauncher.get_returncode')
def test_CheckReturnCode_ReturnCodeIsZero_ReturnsNone(self, mock_get_returncode):
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
mock_get_returncode.return_value = 0
under_test = mock_launcher.check_returncode()
assert under_test is None
@mock.patch('ly_test_tools.launchers.WinLauncher.get_returncode')
def test_CheckReturnCode_ReturnCodeIsNonZero_RaisesError(self, mock_get_returncode):
mock_launcher = ly_test_tools.launchers.WinLauncher(mock.MagicMock(), ["dummy"])
mock_get_returncode.return_value = 1
with pytest.raises(ly_test_tools.launchers.exceptions.CrashError):
mock_launcher.check_returncode()
class TestWinEditor(object):
def test_BinaryPath_DummyPath_AddPathToExe(self):
dummy_path = "dummy_workspace_path"
mock_workspace = mock.MagicMock()
mock_workspace.paths.build_directory.return_value = dummy_path
launcher = ly_test_tools.launchers.WinEditor(mock_workspace, ["some_args"])
under_test = launcher.binary_path()
assert "Editor.exe" in under_test
assert dummy_path in under_test, "workspace path unexpectedly missing"
class TestDedicatedWinLauncher(object):
def test_BinaryPath_DummyPath_AddPathToExe(self):
dummy_path = "dummy_workspace_path"
dummy_project = "dummy_project"
mock_workspace = mock.MagicMock()
mock_workspace.paths.build_directory.return_value = dummy_path
mock_workspace.project = dummy_project
launcher = ly_test_tools.launchers.DedicatedWinLauncher(mock_workspace, ["some_args"])
under_test = launcher.binary_path()
expected = os.path.join(f'{dummy_path}',
f'{dummy_project}.ServerLauncher.exe')
assert under_test == expected
def test_Build_MockWorkspace_DedicatedBuildRequested(self):
mock_workspace = mock.MagicMock()
launcher = ly_test_tools.launchers.DedicatedWinLauncher(mock_workspace, ["some_args"])
launcher.workspace.build(dedicated=True)
mock_workspace.build.assert_called_once_with(dedicated=True)
@@ -0,0 +1,229 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools.log.log_monitor
"""
import io
import unittest.mock as mock
import pytest
import ly_test_tools.log.log_monitor
import ly_test_tools.launchers.platforms.base
pytestmark = pytest.mark.SUITE_smoke
mock_launcher = mock.MagicMock(ly_test_tools.launchers.platforms.base.Launcher)
def mock_log_monitor():
"""Returns a LogMonitor() object with all required parameters & resets attributes for each test."""
log_monitor = ly_test_tools.log.log_monitor.LogMonitor(
launcher=mock_launcher, log_file_path='mock_path')
log_monitor.unexpected_lines_found = []
log_monitor.expected_lines_not_found = []
return log_monitor
class NotALauncher(object):
"""For simulating test failure when the wrong class is passed as a launcher parameter in LogMonitor()."""
pass
@mock.patch('time.sleep', mock.MagicMock)
class TestLogMonitor(object):
def test_Init_HasRequiredParams_ReturnsLogMonitorObject(self):
assert ly_test_tools.log.log_monitor.LogMonitor(
launcher=mock_launcher, log_file_path='some_log_file.log')
def test_CheckExactMatch_HasMatch_ReturnsString(self):
line = '9189.9998188 - INFO - [MainThread] - example.tests.test_system_example - Log Monitoring test 1'
expected_line = 'Log Monitoring test 1'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, expected_line)
assert under_test == expected_line
def test_CheckExactMatch_NoMatch_ReturnsNone(self):
line = 'log line'
expected_line = 'no match'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, expected_line)
assert under_test is None
def test_CheckExactMatch_HasExactMatchWithEscapeCharacter_ReturnsString(self):
line = '9189.9998188 - INFO - [MainThread] - Log Monitoring Test ($1/1).t text'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, line)
assert under_test == line
def test_CheckExactMatch_NoMatchWithEscapeCharacter_ReturnsNone(self):
line = r'<script\\x20type=\"text/javascript\">javascript:alert(1);</script>'
expected_line = 'no match'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, expected_line)
assert under_test is None
def test_CheckExactMatch_HasExactMatchWithEscapeCharacterAtEndOfString_ReturnsString(self):
line = 'Testing with escape character as the last character in this (line)'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, line)
assert under_test == line
def test_CheckExactMatch_HasExactMatchWithNonWordCharacterAtStartOfString_ReturnsString(self):
line = '(Testing with non-word character as the first character in this line'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, line)
assert under_test == line
def test_CheckSubstringMatch_SubstringMatchWithinWord_ReturnsNone(self):
line = 'SubstringMatchWithinWord'
expected_line = 'Match'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, expected_line)
assert under_test is None
def test_CheckSubstringMatch_SubstringMatchBetweenWords_ReturnsMatch(self):
line = 'Substring Match Within Word'
expected_line = 'Match'
under_test = ly_test_tools.log.log_monitor.check_exact_match(line, expected_line)
assert under_test == expected_line
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_AllLinesFound_Success(self):
mock_file = io.StringIO(u'a\nb\nc\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
mock_log_monitor().monitor_log_for_lines(['a', 'b', 'c'], ['d'])
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_AllLinesNotFound_RaisesLogMonitorException(self):
mock_file = io.StringIO(u'a\nb\nc\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_log_monitor().monitor_log_for_lines(['a', 'b', 'c', 'd'], ['c'])
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_SomeUnexpectedLinesFound_RaiseLogMonitorException(self):
mock_file = io.StringIO(u'foo\nbar\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_log_monitor().monitor_log_for_lines(['foo', 'bar'], ['bar'], halt_on_unexpected=True)
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_ExpectedLinesNotFound_RaiseLogMonitorException(self):
mock_file = io.StringIO(u'foo\nbar\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_log_monitor().monitor_log_for_lines(['foo', 'not bar'], [''])
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock)
@mock.patch('os.path.exists')
def test_Monitor_NoLogPathExists_RaiseLogMonitorException(self, mock_path_exists):
mock_path_exists.return_value = False
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_log_monitor().monitor_log_for_lines([''], [''])
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_InvalidLauncherType_RaiseLogMonitorException(self):
invalid_log_monitor = ly_test_tools.log.log_monitor.LogMonitor(
launcher=NotALauncher, log_file_path='mock_path')
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
invalid_log_monitor.monitor_log_for_lines(['foo'], [''])
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_NoneTypeUnexpectedLines_CastsToList(self):
mock_file = io.StringIO(u'foo\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
mock_log_monitor().monitor_log_for_lines(['foo'], None)
@mock.patch('ly_test_tools.log.log_monitor.logging.Logger.warning')
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_NoneTypeExpectedLines_LogsWarningAndCastsToList(self, mock_log_warning):
mock_file = io.StringIO(u'foo\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
mock_log_monitor().monitor_log_for_lines(None, ['bar'])
mock_log_warning.assert_called_once()
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_ExpectedLinesExactMatch_SucceedsOnExactMatch(self):
mock_file = io.StringIO(u'exact match\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
mock_log_monitor().monitor_log_for_lines(['exact match', 'exact', 'match'], [])
@mock.patch('os.path.exists', mock.MagicMock(return_value=True))
def test_Monitor_ExpectedLinesPartialMatch_RaisesLogMonitorException(self):
mock_file = io.StringIO(u'exactlyy\n')
mock_launcher.is_alive.side_effect = [True, True, True, False]
with mock.patch('ly_test_tools.log.log_monitor.open', return_value=mock_file, create=True):
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_log_monitor().monitor_log_for_lines(['exactly'], [])
def test_ValidateResults_Valid_ReturnsTrue(self):
mock_lm = mock_log_monitor()
mock_expected_lines = ['expected_foo']
mock_unexpected_lines = ['unexpected_foo']
mock_expected_lines_not_found = []
mock_unexpected_lines_found = []
under_test = mock_lm._validate_results(mock_expected_lines_not_found, mock_unexpected_lines_found,
mock_expected_lines, mock_unexpected_lines)
assert under_test
def test_ValidateResults_ExpectedLineNotFound_RaisesException(self):
mock_lm = mock_log_monitor()
mock_expected_lines = ['expected_foo']
mock_unexpected_lines = ['unexpected_foo']
mock_expected_lines_not_found = ['expected_foo']
mock_unexpected_lines_found = []
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_lm._validate_results(mock_expected_lines_not_found, mock_unexpected_lines_found, mock_expected_lines,
mock_unexpected_lines)
def test_ValidateResults_UnexpectedLineFound_RaisesException(self):
mock_lm = mock_log_monitor()
mock_expected_lines = ['expected_foo']
mock_unexpected_lines = ['unexpected_foo']
mock_expected_lines_not_found = []
mock_unexpected_lines_found = ['unexpected_foo']
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_lm._validate_results(mock_expected_lines_not_found, mock_unexpected_lines_found, mock_expected_lines,
mock_unexpected_lines)
def test_ValidateResults_ExpectedNotFoundAndUnexpectedFound_RaisesException(self):
mock_lm = mock_log_monitor()
mock_expected_lines = ['expected_foo']
mock_unexpected_lines = ['unexpected_foo']
mock_expected_lines_not_found = ['expected_foo']
mock_unexpected_lines_found = ['unexpected_foo']
with pytest.raises(ly_test_tools.log.log_monitor.LogMonitorException):
mock_lm._validate_results(mock_expected_lines_not_found, mock_unexpected_lines_found, mock_expected_lines,
mock_unexpected_lines)
@@ -0,0 +1,38 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.managers.ly_process_killer
"""
import unittest.mock as mock
import pytest
import ly_test_tools._internal.managers.ly_process_killer
pytestmark = pytest.mark.SUITE_smoke
class TestProcessKiller(object):
@mock.patch('ly_test_tools.environment.process_utils.process_exists')
def test_DetectLumberyardProcesses_ValidProcessesList_ReturnsDetectedProcessesList(self, mock_process_exists):
mock_process_exists.side_effect = [True, False]
mock_process_list = ['foo', 'bar']
under_test = ly_test_tools._internal.managers.ly_process_killer.detect_lumberyard_processes(
processes_list=mock_process_list)
assert under_test == ['foo']
def test_KillProcesses_ProcessesListIsNotList_RaisesLyProcessKillerException(self):
with pytest.raises(ly_test_tools._internal.managers.ly_process_killer.LyProcessKillerException):
ly_test_tools._internal.managers.ly_process_killer.kill_processes(processes_list={})
@@ -0,0 +1,99 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.managers.platforms.mac
"""
import unittest.mock as mock
import os
import pytest
from ly_test_tools._internal.managers.platforms.mac import (
_MacResourceLocator, MacWorkspaceManager,
CACHE_DIR, CONFIG_FILE)
from ly_test_tools import MAC
pytestmark = pytest.mark.SUITE_smoke
if not MAC:
pytestmark = pytest.mark.skipif(
not MAC,
reason="test_manager_platforms_mac.py only runs on Mac")
mock_engine_root = 'mock_engine_root'
mock_dev_path = 'mock_dev_path'
mock_build_directory = 'mock_build_directory'
mock_project = 'mock_project'
mock_tmp_path = 'mock_tmp_path'
mock_output_path = 'mock_output_path'
mac_resource_locator = _MacResourceLocator(
build_directory=mock_build_directory,
project=mock_project)
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
class TestMacResourceLocator(object):
def test_PlatformConfigFile_HasPath_ReturnsPath(self):
expected = os.path.join(
mac_resource_locator.dev(),
CONFIG_FILE)
assert mac_resource_locator.platform_config_file() == expected
def test_PlatformCache_HasPath_ReturnsPath(self):
expected = os.path.join(
mac_resource_locator.project_cache(),
CACHE_DIR)
assert mac_resource_locator.platform_cache() == expected
def test_ProjectLog_HasPath_ReturnsPath(self):
expected = os.path.join(
mac_resource_locator.platform_cache(),
'user',
'log')
assert mac_resource_locator.project_log() == expected
def test_ProjectScreenshots_HasPath_ReturnsPath(self):
expected = os.path.join(
mac_resource_locator.platform_cache(),
'user',
'ScreenShots')
assert mac_resource_locator.project_screenshots() == expected
def test_EditorLog_HasPath_ReturnsPath(self):
expected = os.path.join(
mac_resource_locator.project_log(),
'editor.log')
assert mac_resource_locator.editor_log() == expected
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
class TestMacWorkspaceManager(object):
def test_Init_SetDummyParams_ReturnsMacWorkspaceManager(self):
mac_workspace_manager = MacWorkspaceManager(
build_directory=mock_build_directory,
project=mock_project,
tmp_path=mock_tmp_path,
output_path=mock_output_path)
assert type(mac_workspace_manager) == MacWorkspaceManager
assert mac_workspace_manager.paths.build_directory() == mock_build_directory
assert mac_workspace_manager.paths._project == mock_project
assert mac_workspace_manager.tmp_path == mock_tmp_path
assert mac_workspace_manager.output_path == mock_output_path
@@ -0,0 +1,103 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.managers.platforms.windows
"""
import unittest.mock as mock
import os
import pytest
from ly_test_tools._internal.managers.platforms.windows import (
_WindowsResourceLocator, WindowsWorkspaceManager,
CACHE_DIR, CONFIG_FILE)
from ly_test_tools import WINDOWS
pytestmark = pytest.mark.SUITE_smoke
if not WINDOWS:
pytestmark = pytest.mark.skipif(
not WINDOWS,
reason="test_manager_platforms_windows.py only runs on Windows")
mock_engine_root = 'mock_engine_root'
mock_dev_path = 'mock_dev_path'
mock_build_directory = 'mock_build_directory'
mock_project = 'mock_project'
mock_tmp_path = 'mock_tmp_path'
mock_output_path = 'mock_output_path'
windows_resource_locator = _WindowsResourceLocator(
build_directory=mock_build_directory,
project=mock_project)
windows_workspace_manager = WindowsWorkspaceManager(
build_directory=mock_build_directory,
project=mock_project,
tmp_path=mock_tmp_path,
output_path=mock_output_path)
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
class TestWindowsResourceLocator(object):
def test_PlatformConfigFile_HasPath_ReturnsPath(self):
expected = os.path.join(
windows_resource_locator.dev(),
CONFIG_FILE)
assert windows_resource_locator.platform_config_file() == expected
def test_PlatformCache_HasPath_ReturnsPath(self):
expected = os.path.join(
windows_resource_locator.project_cache(), CACHE_DIR)
assert windows_resource_locator.platform_cache() == expected
def test_ProjectLog_HasPath_ReturnsPath(self):
expected = os.path.join(
windows_resource_locator.platform_cache(),
'user',
'log')
assert windows_resource_locator.project_log() == expected
def test_ProjectScreenshots_HasPath_ReturnsPath(self):
expected = os.path.join(
windows_resource_locator.platform_cache(),
'user',
'ScreenShots')
assert windows_resource_locator.project_screenshots() == expected
def test_EditorLog_HasPath_ReturnsPath(self):
expected = os.path.join(
windows_resource_locator.project_log(),
'editor.log')
assert windows_resource_locator.editor_log() == expected
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
class TestWindowsWorkspaceManager(object):
@mock.patch('ly_test_tools.environment.reg_cleaner.create_ly_keys')
def test_SetRegistryKeys_NewWorkspaceManager_KeyCreateCalled(self, mock_create_keys):
windows_workspace_manager.set_registry_keys()
mock_create_keys.assert_called_once()
@mock.patch('ly_test_tools.environment.reg_cleaner.clean_ly_keys')
def test_ClearSettings_NewWorkspaceManager_KeyClearCalled(self, mock_clear_keys):
windows_workspace_manager.clear_settings()
mock_clear_keys.assert_called_with(exception_list=r"SOFTWARE\Amazon\Lumberyard\Identity")
@@ -0,0 +1,359 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import unittest.mock as mock
import psutil
import pytest
import subprocess
import unittest
import ly_test_tools.environment.process_utils as process_utils
from ly_test_tools import WINDOWS
pytestmark = pytest.mark.SUITE_smoke
class TestSubprocessCheckOutputWrapper(unittest.TestCase):
@mock.patch('subprocess.check_output')
@mock.patch('logging.Logger.error')
def test_CheckOutput_FailingCommand_UsesCorrectLoggingLevel(self, mock_log_err, mock_sub_output):
mock_sub_output.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
cmd = ['test', 'cmd']
with pytest.raises(subprocess.CalledProcessError):
process_utils.check_output(cmd)
mock_log_err.assert_called_once()
@mock.patch('subprocess.check_output')
@mock.patch('logging.Logger.info')
def test_CheckOutput_SuccessfulCommand_UsesCorrectLoggingLevel(self, mock_log_info, mock_sub_output):
mock_sub_output.return_value = 'Output returned successfully'.encode()
cmd = ['test', 'cmd']
expected_logger_info_calls = 2
process_utils.check_output(cmd)
self.assertEqual(expected_logger_info_calls, mock_log_info.call_count)
@mock.patch('subprocess.check_output')
def test_CheckOutput_FailingCommand_RaisesCalledProcessError(self, mock_sub_output):
mock_sub_output.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
cmd = ['test', 'cmd']
with self.assertRaises(subprocess.CalledProcessError):
process_utils.check_output(cmd)
@mock.patch('subprocess.check_output')
def test_CheckOutput_CmdPassedAsString_ReturnsOutput(self, mock_sub_output):
expected_output = 'Output returned successfully'
mock_sub_output.return_value = expected_output.encode()
cmd = 'test cmd'
actual_output = process_utils.check_output(cmd)
mock_sub_output.assert_called_once()
self.assertEqual(expected_output, actual_output)
class TestSubprocessCheckOutputWrapperSafe(unittest.TestCase):
@mock.patch('subprocess.check_output')
@mock.patch('logging.Logger.warning')
def test_SafeCheckOutput_FailingCommand_UsesCorrectLoggingLevel(self, mock_log_warn, mock_sub_output):
mock_sub_output.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
cmd = ['test', 'cmd']
process_utils.safe_check_output(cmd)
mock_log_warn.assert_called_once()
@mock.patch('subprocess.check_output')
@mock.patch('logging.Logger.info')
def test_SafeCheckOutput_SuccessfulCommand_UsesCorrectLoggingLevel(self, mock_log_info, mock_sub_output):
mock_sub_output.return_value = 'Output returned successfully'.encode()
cmd = ['test', 'cmd']
expected_logger_info_calls = 2
process_utils.check_output(cmd)
self.assertEqual(expected_logger_info_calls, mock_log_info.call_count)
@mock.patch('subprocess.check_output')
def test_SafeCheckOutput_FailingCommand_ReturnsOutput(self, mock_sub_output):
expected_output = 'Output returned successfully'
mock_sub_output.side_effect = subprocess.CalledProcessError(1, 'cmd', expected_output)
cmd = ['test', 'cmd']
actual_output = process_utils.safe_check_output(cmd)
mock_sub_output.assert_called_once()
self.assertEqual(expected_output, actual_output)
@mock.patch('subprocess.check_output')
def test_SafeCheckOutput_CmdPassedAsString_ReturnsOutput(self, mock_sub_output):
expected_output = 'Output returned successfully'
mock_sub_output.return_value = expected_output.encode()
cmd = 'test cmd'
actual_output = process_utils.safe_check_output(cmd)
mock_sub_output.assert_called_once()
self.assertEqual(expected_output, actual_output)
class TestSubprocessCheckCallWrapper(unittest.TestCase):
@mock.patch('subprocess.check_call')
@mock.patch('logging.Logger.error')
def test_CheckCall_FailingCommand_UsesCorrectLoggingLevel(self, mock_log_err, mock_sub_call):
mock_sub_call.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
cmd = ['test', 'cmd']
with pytest.raises(subprocess.CalledProcessError):
process_utils.check_call(cmd)
mock_log_err.assert_called_once()
@mock.patch('subprocess.check_call')
@mock.patch('logging.Logger.info')
def test_CheckOutput_SuccessfulCommand_UsesCorrectLoggingLevel(self, mock_log_info, mock_sub_call):
mock_sub_call.return_value = 0
cmd = ['test', 'cmd']
expected_logger_info_calls = 2
process_utils.check_call(cmd)
self.assertEqual(expected_logger_info_calls, mock_log_info.call_count)
@mock.patch('subprocess.check_call')
def test_CheckCall_FailingCommand_RaisesCalledProcessError(self, mock_sub_call):
mock_sub_call.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
cmd = ['test', 'cmd']
with self.assertRaises(subprocess.CalledProcessError):
process_utils.check_call(cmd)
mock_sub_call.assert_called_once()
@mock.patch('subprocess.check_call')
def test_CheckCall_CmdPassedAsString_ReturnsSuccess(self, mock_sub_call):
expected_retcode = 0
mock_sub_call.returncode = expected_retcode
cmd = 'test cmd'
actual_retcode = process_utils.check_call(cmd)
mock_sub_call.assert_called_once()
self.assertEqual(expected_retcode, actual_retcode)
class TestSubprocessCheckCallWrapperSafe(unittest.TestCase):
@mock.patch('subprocess.check_call')
@mock.patch('logging.Logger.warning')
def test_SafeCheckCall_FailingCommand_UsesCorrectLoggingLevel(self, mock_log_warn, mock_sub_call):
mock_sub_call.side_effect = subprocess.CalledProcessError(1, 'cmd', 'output')
cmd = ['test', 'cmd']
process_utils.safe_check_call(cmd)
mock_log_warn.assert_called_once()
@mock.patch('subprocess.check_call')
@mock.patch('logging.Logger.info')
def test_CheckOutput_SuccessfulCommand_UsesCorrectLoggingLevel(self, mock_log_info, mock_sub_call):
mock_sub_call.return_value = 0
cmd = ['test', 'cmd']
expected_logger_info_calls = 2
process_utils.safe_check_call(cmd)
self.assertEqual(expected_logger_info_calls, mock_log_info.call_count)
@mock.patch('subprocess.check_call')
def test_SafeCheckCall_FailingCommand_ReturnsFailureCode(self, mock_sub_call):
expected_retcode = 1
mock_sub_call.side_effect = subprocess.CalledProcessError(expected_retcode, 'cmd', 'output')
cmd = ['test', 'cmd']
actual_retcode = process_utils.safe_check_call(cmd)
mock_sub_call.assert_called_once()
self.assertEqual(expected_retcode, actual_retcode)
@mock.patch('subprocess.check_call')
def test_SafeCheckCall_CmdPassedAsString_ReturnsSuccess(self, mock_sub_call):
expected_retcode = 0
mock_sub_call.returncode = expected_retcode
cmd = 'test cmd'
actual_retcode = process_utils.safe_check_call(cmd)
mock_sub_call.assert_called_once()
self.assertEqual(expected_retcode, actual_retcode)
@pytest.mark.skipif(
not WINDOWS,
reason="tests.unit.test_process_utils is restricted to the Windows platform.")
class TestCloseWindowsProcess(unittest.TestCase):
@mock.patch('ly_test_tools.environment.process_utils.WINDOWS', False)
def test_CloseWindowsProccess_NotOnWindows_Error(self):
with pytest.raises(NotImplementedError):
process_utils.close_windows_process(1)
def test_CloseWindowsProccess_IdNone_Error(self):
with pytest.raises(TypeError):
process_utils.close_windows_process(None)
@mock.patch('psutil.Process')
def test_CloseWindowsProccess_ProcDNE_Error(self, mock_psutil):
mock_proc = mock.MagicMock()
mock_proc.is_running.return_value = False
mock_psutil.return_value = mock_proc
with pytest.raises(TypeError):
process_utils.close_windows_process(None)
@mock.patch('psutil.Process')
@mock.patch('ctypes.windll.user32.EnumWindows')
@mock.patch('ctypes.windll', mock.MagicMock())
@mock.patch('ly_test_tools.environment.waiter.wait_for', mock.MagicMock())
def test_CloseProcess_MockedWindll_VerifyMock(self, mock_enum, mock_psutil):
mock_proc = mock.MagicMock()
mock_proc.is_running.return_value = True
mock_psutil.return_value = mock_proc
process_utils.close_windows_process(3)
mock_enum.assert_called_once()
class Test(unittest.TestCase):
@mock.patch("ly_test_tools.environment.process_utils._safe_get_processes")
def test_ProcExists_HasExtension_Found(self, mock_get_proc):
name = "dummy.exe"
proc_mock = mock.MagicMock()
proc_mock.name.return_value = name
mock_get_proc.return_value = [proc_mock]
result = process_utils.process_exists(name)
self.assertTrue(result)
proc_mock.name.assert_called()
@mock.patch("ly_test_tools.environment.process_utils._safe_get_processes")
def test_ProcExists_NoExtension_Ignored(self, mock_get_proc):
name = "dummy.exe"
proc_mock = mock.MagicMock()
proc_mock.name.return_value = name
mock_get_proc.return_value = [proc_mock]
result = process_utils.process_exists("dummy")
self.assertFalse(result)
proc_mock.name.assert_called()
@mock.patch("ly_test_tools.environment.process_utils._safe_get_processes")
def test_ProcExistsIgnoreExtension_NoExtension_Found(self, mock_get_proc):
name = "dummy.exe"
proc_mock = mock.MagicMock()
proc_mock.name.return_value = name
mock_get_proc.return_value = [proc_mock]
result = process_utils.process_exists("dummy", ignore_extensions=True)
self.assertTrue(result)
proc_mock.name.assert_called()
@mock.patch('ly_test_tools.environment.process_utils._safe_kill_process', mock.MagicMock)
@mock.patch('ly_test_tools.environment.process_utils._safe_get_processes')
def test_KillProcNamed_MockKill_SilentSuccess(self, mock_get_proc):
name = "dummy.exe"
proc_mock = mock.MagicMock()
proc_mock.name.return_value = name
mock_get_proc.return_value = [proc_mock]
process_utils.kill_processes_named("dummy", ignore_extensions=True)
proc_mock.name.assert_called()
@mock.patch('ly_test_tools.environment.process_utils._safe_kill_process', mock.MagicMock)
@mock.patch('ly_test_tools.environment.process_utils._safe_get_processes')
@mock.patch('os.path.exists')
def test_KillProcFrom_MockKill_SilentSuccess(self, mock_path, mock_get_proc):
mock_path.return_value = True
proc_mock = mock.MagicMock()
mock_get_proc.return_value = [proc_mock]
process_utils.kill_processes_started_from("dummy_path")
@mock.patch('ly_test_tools.environment.process_utils._safe_kill_process')
@mock.patch('psutil.Process')
def test_KillProcPid_ProcRunning_Killed(self, mock_psutil, mock_kill):
mock_proc = mock.MagicMock()
mock_proc.is_running.return_value = True
mock_psutil.return_value = mock_proc
process_utils.kill_process_with_pid(1)
mock_kill.assert_called()
@mock.patch('ly_test_tools.environment.process_utils._safe_kill_process', mock.MagicMock)
@mock.patch('psutil.Process')
def test_KillProcPid_NoProc_SilentPass(self, mock_psutil):
mock_proc = mock.MagicMock()
mock_proc.is_running.return_value = False
mock_psutil.return_value = mock_proc
process_utils.kill_process_with_pid(1)
@mock.patch('ly_test_tools.environment.process_utils._safe_kill_process', mock.MagicMock)
@mock.patch('psutil.Process')
def test_KillProcPidRaiseOnMissing_NoProc_Raises(self, mock_psutil):
mock_proc = mock.MagicMock()
mock_proc.is_running.return_value = False
mock_psutil.return_value = mock_proc
with self.assertRaises(RuntimeError):
process_utils.kill_process_with_pid(1, raise_on_missing=True)
def test_SafeKillProc_HappyPath_Success(self):
proc_mock = mock.MagicMock()
proc_mock.is_running.return_value = False
process_utils._safe_kill_process(proc_mock)
proc_mock.kill.assert_called()
proc_mock.is_running.assert_called()
@mock.patch('ly_test_tools.environment.waiter.wait_for')
@mock.patch('logging.Logger.warning')
def test_SafeKillProc_KillRetriesFail_LogsFailure(self, mock_log_warn, mock_wait):
mock_wait.side_effect = psutil.AccessDenied()
proc_mock = mock.MagicMock()
process_utils._safe_kill_process(proc_mock)
proc_mock.kill.assert_called()
mock_wait.assert_called()
mock_log_warn.assert_called()
@mock.patch('psutil.process_iter')
@mock.patch('logging.Logger.debug')
def test_SafeGetProc_CannotAccess_LogAndReturnNone(self, mock_log_debug, mock_psiter):
mock_psiter.side_effect = psutil.Error()
under_test = process_utils._safe_get_processes()
self.assertIsNone(under_test)
mock_log_debug.assert_called()
@mock.patch('psutil.process_iter')
@mock.patch('logging.Logger.debug')
def test_SafeGetProc_HappyPathDummy_ReturnDummy(self, mock_log_debug, mock_psiter):
dummy = object()
mock_psiter.return_value = dummy
under_test = process_utils._safe_get_processes()
self.assertIs(under_test, dummy)
mock_log_debug.assert_not_called()
@@ -0,0 +1,108 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import pytest
import unittest.mock as mock
from pytest_mock import MockFixture
import ly_test_tools._internal.log.py_logging_util as py_logging_util
pytestmark = pytest.mark.SUITE_smoke
class TestTerminateLogging(object):
def test_TerminateLogging_HandlersUninitialized_NoHandlersRemoved(self, mocker):
# type: (MockFixture) -> None
mock_getLogger = mocker.patch("logging.getLogger") # type: MagicMock
mock_removeHandler = mock_getLogger.return_value.removeHandler # type: MagicMock
py_logging_util.terminate_logging()
mock_removeHandler.assert_not_called()
def test_TerminateLogging_HandlersInitialized_HandlersRemoved(self, mocker):
# type: (MockFixture) -> None
mock_getLogger = mocker.patch("logging.getLogger") # type: MagicMock
mock_removeHandler = mock_getLogger.return_value.removeHandler # type: MagicMock
mock_stream_handler = "Mock Stream Handler"
mock_info_file_handler = "Mock Info File Handler"
mock_debug_file_handler = "Mock Debug File Handler"
py_logging_util._stream_handler = mock_stream_handler
py_logging_util._info_file_handler = mock_info_file_handler
py_logging_util._debug_file_handler = mock_debug_file_handler
py_logging_util.terminate_logging()
calls = [
mock.call(mock_stream_handler),
mock.call(mock_info_file_handler),
mock.call(mock_debug_file_handler),
]
mock_removeHandler.assert_has_calls(calls)
class TestInitializeLogging(object):
@mock.patch("logging.getLogger", scope='module')
def test_InitializeLogging_AddHandlerCalled_CalledThrice(self, mock_get_logger):
dummy_log_path = "dummy_log_path"
dummy_info_path = "dummy_info_path"
py_logging_util._stream_handler = None
py_logging_util._info_file_handler = None
py_logging_util._debug_file_handler = None
mock_add_handler = mock_get_logger.return_value.addHandler
py_logging_util.initialize_logging(dummy_info_path, dummy_log_path)
assert mock_add_handler.call_count == 3
py_logging_util.terminate_logging()
@mock.patch("logging.getLogger", scope='module')
def test_InitializeLogging_CheckLoggerCalled_LoggerCalledOnce(self, mock_get_logger):
dummy_log_path = "dummy_path"
dummy_info_path = "dummy_path"
py_logging_util.initialize_logging(dummy_info_path, dummy_log_path)
mock_get_logger.assert_called_once()
@mock.patch("logging.getLogger", scope='module')
def test_InitializeLogging_SetLogLevelValidArgs_ValidArgsPassed(self, mock_get_logger):
dummy_log_path = "dummy_path"
dummy_info_path = "dummy_path"
mock_setLevel = mock_get_logger.return_value.setLevel
py_logging_util.initialize_logging(dummy_info_path, dummy_log_path)
mock_setLevel.assert_called_with(10) # logging.DEBUG = 10
def test_InitializeLogging_CheckHandlerInitialized_HandlerNotNone(self):
dummy_log_path = "dummy_path"
dummy_info_path = "dummy_path"
py_logging_util.initialize_logging(dummy_info_path,dummy_log_path)
assert py_logging_util._debug_file_handler is not None
assert py_logging_util._info_file_handler is not None
assert py_logging_util._stream_handler is not None
@mock.patch("logging.StreamHandler.setFormatter", scope='module')
def test_InitializeLogging_CheckFormatting_HandlerFormattingIsCorrect(self,mock_stream_handler_formatter):
dummy_log_path = "dummy_path"
dummy_info_path = "dummy_path"
py_logging_util._stream_handler = None
py_logging_util._info_file_handler = None
py_logging_util._debug_file_handler = None
py_logging_util.initialize_logging(dummy_info_path,dummy_log_path)
#example of formatted string : 7024.00016785 - DEBUG - [MainThread] - ly_test_tools.launchers.platforms.win.launcher - Initialized Windows Launcher
format_string = "%(relativeCreated)s - %(levelname)s - [%(threadName)s] - %(name)s - %(message)s"
assert mock_stream_handler_formatter.call_args[0][0]._fmt == format_string
@@ -0,0 +1,92 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for ~/ly_test_tools/report/rad_telemetry.py
"""
import unittest.mock as mock
import os
import pytest
import ly_test_tools.report.rad_telemetry
from ly_test_tools import WINDOWS
pytestmark = pytest.mark.SUITE_smoke
_RAD_DEFAULT_PORT = 4719
_CREATE_NEW_PROCESS_GROUP = 0x00000200
_DETACHED_PROCESS = 0x00000008
_WINDOWS_FLAGS = _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS
RAD_TOOLS_SUBPATH = os.path.join("dev", "Gems", "RADTelemetry", "Tools")
@pytest.mark.skipif(
not WINDOWS,
reason="tests.unit.test_rad_telemetry is restricted to the Windows platform.")
class TestRADTelemetry:
@mock.patch('ly_test_tools.environment.process_utils.check_call')
@mock.patch('ly_test_tools.environment.process_utils.safe_check_call')
def test_SetFirewallRules_ShowRuleResultNotZero_CallsAddRule(self, mock_safe_call, mock_call):
ly_test_tools.report.rad_telemetry.set_firewall_rules()
mock_safe_call.call_args_list = [
mock.call(['netsh', 'advfirewall', 'firewall', 'show', 'rule', 'name=RADTelemetry', 'dir=in']),
mock.call(['netsh', 'advfirewall', 'firewall', 'show', 'rule', 'name=RADTelemetry', 'dir=out']),
]
mock_call.call_args_list = [
mock.call(
['netsh', 'advfirewall', 'firewall', 'add', 'rule', 'name=RADTelemetry', 'dir=in',
'action=allow', 'protocol=TCP', 'localport={}'.format(_RAD_DEFAULT_PORT)]),
mock.call(
['netsh', 'advfirewall', 'firewall', 'add', 'rule', 'name=RADTelemetry', 'dir=out',
'action=allow', 'protocol=TCP', 'localport={}'.format(_RAD_DEFAULT_PORT)]),
]
assert mock_call.call_count == 2
assert mock_safe_call.call_count == 2
@mock.patch('ly_test_tools.environment.process_utils.check_call')
@mock.patch('ly_test_tools.environment.process_utils.safe_check_call')
def test_SetFirewallRules_ShowRuleResultEqualsZero_AddRuleNotCalled(self, mock_safe_call, mock_call):
mock_safe_call.return_value = 0
ly_test_tools.report.rad_telemetry.set_firewall_rules()
mock_call.assert_not_called()
assert mock_safe_call.call_count == 2
@mock.patch('subprocess.Popen')
def test_LaunchServer_ValidDevPath_PopenSuccess(self, mock_popen):
mock_server_path = os.path.join('dev_path', RAD_TOOLS_SUBPATH, "tm_server.exe")
ly_test_tools.report.rad_telemetry.launch_server('dev_path')
mock_popen.assert_called_once_with([mock_server_path], creationflags=_WINDOWS_FLAGS, close_fds=True)
@mock.patch('ly_test_tools.environment.process_utils.kill_processes_started_from')
def test_TerminateServer_ValidDevPath_KillsRADProcess(self, mock_kill_process):
mock_rad_path = os.path.join('dev_path', RAD_TOOLS_SUBPATH)
ly_test_tools.report.rad_telemetry.terminate_servers('dev_path')
mock_kill_process.assert_called_once_with(mock_rad_path)
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_TerminateServer_ValidDevPath_KillsRADProcess(self, mock_call):
mock_get_folder_path = os.path.join('dev_path', RAD_TOOLS_SUBPATH, "tm_server.exe")
mock_call.return_value = 'test'
under_test = ly_test_tools.report.rad_telemetry.get_capture_path('dev_path')
mock_call.assert_called_once_with([mock_get_folder_path])
assert under_test == 'test'
@@ -0,0 +1,93 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import pytest
from pytest_mock import MockFixture
import ly_test_tools.environment.reg_cleaner
from ly_test_tools import WINDOWS
pytestmark = pytest.mark.SUITE_smoke
if not WINDOWS:
pytestmark = pytest.mark.skipif(
not WINDOWS,
reason="tests.unit.test_reg_cleaner is restricted to the Windows platform.")
def test_DeleteKey_KeyDoesNotExist_SilentlyFails(mocker):
# type: (MockFixture) -> None
mock_delete_child_keys_and_values = mocker.patch(
"ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values")
mock_delete_child_keys_and_values.side_effect = WindowsError
ly_test_tools.environment.reg_cleaner._delete_key("key_1")
def test_DeleteKey_KeyIsEmpty_KeyDeleted(mocker):
# type: (MockFixture) -> None
mockwinreg = mocker.patch("ly_test_tools.environment.reg_cleaner.winreg")
mock_delete_child_keys_and_values = mocker.patch(
"ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values")
mock_delete_child_keys_and_values.return_value = True
ly_test_tools.environment.reg_cleaner._delete_key("key_1")
assert mockwinreg.DeleteKey.called
def test_DeleteKey_KeyIsNotEmpty_KeyNotDeleted(mocker):
# type: (MockFixture) -> None
mockwinreg = mocker.patch("ly_test_tools.environment.reg_cleaner.winreg")
mock_delete_child_keys_and_values = mocker.patch(
"ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values")
mock_delete_child_keys_and_values.return_value = False
ly_test_tools.environment.reg_cleaner._delete_key("key_1")
assert not mockwinreg.DeleteKey.called
def test_DeleteChildKeysAndValues_KeyDoesNotExist_RaisesWindowsError(mocker):
# type: (MockFixture) -> None
mockwinreg = mocker.patch("ly_test_tools.environment.reg_cleaner.winreg")
mockwinreg.OpenKey.side_effect = WindowsError
with pytest.raises(WindowsError):
ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values("key_1")
def test_DeleteChildKeysAndValues_KeyIsEmpty_ReturnsTrue(mocker):
# type: (MockFixture) -> None
mockwinreg = mocker.patch("ly_test_tools.environment.reg_cleaner.winreg")
mockwinreg.EnumKey.side_effect = WindowsError
mockwinreg.EnumValue.side_effect = WindowsError
assert ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values("key_1")
def test_DeleteChildKeysAndValues_KeyWithNoChildrenInExceptionList_ReturnsTrue(mocker):
# type: (MockFixture) -> None
mockwinreg = mocker.patch("ly_test_tools.environment.reg_cleaner.winreg")
mockwinreg.EnumKey.side_effect = ["child_key_1", WindowsError, WindowsError]
mockwinreg.EnumValue.side_effect = [WindowsError, ("child_value_1", None, None), WindowsError]
assert ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values("key_1")
def test_DeleteChildKeysAndValues_KeyWithChildrenInExceptionList_ReturnsFalse(mocker):
# type: (MockFixture) -> None
mockwinreg = mocker.patch("ly_test_tools.environment.reg_cleaner.winreg")
mockwinreg.EnumKey.side_effect = ["child_key_1", WindowsError]
mockwinreg.EnumValue.side_effect = [("child_value_1", None, None), WindowsError]
exception_list = ["key_1\\child_key_1", "key_1\\child_value_1"]
assert not ly_test_tools.environment.reg_cleaner._delete_child_keys_and_values("key_1", exception_list)
@@ -0,0 +1,104 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit test for ly_test_tools.image.screenshot_compare_qssim
"""
import unittest.mock as mock
import numpy as np
import pytest
import ly_test_tools.image.screenshot_compare_qssim as screenshot_compare
pytestmark = pytest.mark.SUITE_smoke
class TestScreenshotCompare(object):
def test_QuaternionMatrixConj_4x3Matrix_ValidConjugate(self):
given_matrix = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10 , 11, 12]]])
expected_conjugateMatrix = np.array([[[1, -2, -3, -4], [5, -6, -7, -8], [9, -10, -11, -12]]])
result_conjugateMatrix = screenshot_compare._quaternion_matrix_conj(given_matrix)
assert np.array_equal(result_conjugateMatrix,expected_conjugateMatrix)
def test_QuaternionMatrixDot_4x3matrix_ValidDotProduct(self):
given_matrix = np.array([[[0, 0, 0, 2], [0, 0, 4, 0], [0, 0, 8, 0]]])
expected_answer = np.array([[2, 4, 8]])
result_matrix = screenshot_compare._quaternion_matrix_dot(given_matrix, given_matrix)
assert np.array_equal(expected_answer,result_matrix)
@mock.patch('ly_test_tools.image.screenshot_compare_qssim._quaternion_matrix_dot')
def test_QuaternionMatrixNorm_DotProductUsed_AssertDotProductCalled(self, mock_matrixDotProduct):
given_matrix = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
screenshot_compare._quaternion_matrix_norm(given_matrix)
mock_matrixDotProduct.assert_called_once()
mock_matrixDotProduct.assert_called_with(given_matrix,given_matrix)
@mock.patch('ly_test_tools.image.screenshot_compare_qssim._quaternion_matrix_norm')
def test_QuaternionMatrixDivide_NormCalledForSecondMatrix_AssertNormCalled(self, mock_matrixNorm):
matrix_a = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
matrix_b = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
mock_matrixNorm.return_value = np.array([[1,2,3]])
screenshot_compare._quaternion_matrix_div(matrix_a, matrix_b)
mock_matrixNorm.assert_called_with(matrix_b)
@mock.patch('numpy.divide',mock.MagicMock())
@mock.patch('ly_test_tools.image.screenshot_compare_qssim._quaternion_matrix_mult',mock.MagicMock())
@mock.patch('ly_test_tools.image.screenshot_compare_qssim._quaternion_matrix_conj')
def test_QuaternionMatrixDivide_ConjugateCalledForSecondMatrix_AssertConjugateCalled(self, mock_matrixConjugate):
matrix_a = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
matrix_b = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
screenshot_compare._quaternion_matrix_div(matrix_a, matrix_b)
mock_matrixConjugate.assert_called_with(matrix_b)
@mock.patch('numpy.divide',mock.MagicMock())
@mock.patch('ly_test_tools.image.screenshot_compare_qssim._quaternion_matrix_conj')
@mock.patch('ly_test_tools.image.screenshot_compare_qssim._quaternion_matrix_mult')
def test_QuaternionMatrixDivide_MultiplyCalledForMatAConjB_AssertMultiplyCalled(self, mock_matrixMultiply,mock_matrixConjugate):
matrix_a = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
matrix_b = np.array([[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]])
mock_conjugate_return = np.array([[[1, -2, -3, -4], [5, -6, -7, -8], [9, -10, -11, -12]]])
mock_matrixConjugate.return_value = mock_conjugate_return
screenshot_compare._quaternion_matrix_div(matrix_a, matrix_b)
mock_matrixMultiply.assert_called_with(matrix_a,mock_conjugate_return)
@mock.patch('imageio.imread')
@mock.patch('imageio.imwrite',mock.MagicMock())
def test_qssim_CheckSameImage_ShouldReturnOne(self, mock_imageRead):
matrix_a = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
matrix_b = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
mock_imageRead.side_effect = [matrix_a,matrix_b]
assert screenshot_compare.qssim('test1.jpg', 'test2.jpg') == 1
@mock.patch('imageio.imread')
@mock.patch('imageio.imwrite',mock.MagicMock())
def test_qssim_CheckAlmostSameImage_GreaterThanHalf(self, mock_imageRead):
matrix_a = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
matrix_b = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 19]]])
mock_imageRead.side_effect = [matrix_a,matrix_b]
assert screenshot_compare.qssim('test1.jpg', 'test2.jpg') > 0.5
@mock.patch('imageio.imread')
@mock.patch('imageio.imwrite',mock.MagicMock())
def test_qssim_CheckDifferentImage_ShouldNotReturnOne(self, mock_imageRead):
matrix_a = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
matrix_b = np.array([[[11, 12, 13], [14, 15, 16], [17, 18, 19]]])
mock_imageRead.side_effect = [matrix_a,matrix_b]
assert screenshot_compare.qssim('test1.jpg', 'test2.jpg') != 1
@mock.patch('imageio.imread')
@mock.patch('imageio.imwrite')
def test_qssim_CheckDiffImageSaved_AssertImSave(self, mock_imageSave, mock_imageRead):
matrix_a = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
matrix_b = np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
mock_imageRead.side_effect = [matrix_a,matrix_b]
screenshot_compare.qssim('test1.jpg', 'test2.jpg')
mock_imageSave.assert_called()
@@ -0,0 +1,160 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import unittest.mock as mock
import unittest
import pytest
import ly_test_tools.lumberyard.settings
pytestmark = pytest.mark.SUITE_smoke
class MockDocumentInput(object):
def __init__(self, lines):
self._lines = lines
def __iter__(self):
return (line for line in self._lines)
def close(self):
pass
class TestReplaceLineInFile(unittest.TestCase):
def setUp(self):
self.file_name = 'file1'
self.search_for = 'search_for'
self.replace_with = 'replace_with'
self.mock_file_content = [
"Setting1=Foo",
";Setting2=Bar",
"Setting3=Baz ",
"Setting4="]
@mock.patch('fileinput.input')
@mock.patch('os.path.isfile')
@mock.patch('logging.Logger.warning')
def test_ReplaceLineInFile_FileInUse_NoRaise(self, mock_log_warning, mock_path_isfile, mock_input):
mock_path_isfile.return_value = True
mock_input.side_effect = PermissionError()
try:
with mock.patch('__builtin__.open'):
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, self.search_for, self.replace_with)
except ImportError:
with mock.patch('builtins.open'):
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, self.search_for, self.replace_with)
mock_log_warning.assert_called_once()
@mock.patch('fileinput.input')
@mock.patch('os.path.isfile')
@mock.patch('logging.Logger.error')
def test_ReplaceLineInFile_Error_Raises(self, mock_log_error, mock_path_isfile, mock_input):
mock_path_isfile.return_value = True
mock_input.side_effect = NotImplementedError()
with pytest.raises(NotImplementedError):
try:
with mock.patch('__builtin__.open'):
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, self.search_for, self.replace_with)
except ImportError:
with mock.patch('builtins.open'):
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, self.search_for, self.replace_with)
@mock.patch('fileinput.input')
@mock.patch('os.path.isfile')
def test_ReplaceLineInFile_FileFound_NoRaise(self, mock_path_isfile, mock_input):
mock_path_isfile.return_value = True
try:
with mock.patch('__builtin__.open'):
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, self.search_for, self.replace_with)
except ImportError:
with mock.patch('builtins.open'):
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, self.search_for, self.replace_with)
mock_input.return_value.close.assert_called_once_with()
@mock.patch('os.path.isfile')
@mock.patch('fileinput.input')
@mock.patch('sys.stdout')
def test_ReplaceLineInFile_SettingMatch_SettingReplaced(self, mock_stdout, mock_input, mock_isfile):
mock_isfile.return_value = True
mock_input.return_value = MockDocumentInput(self.mock_file_content)
expected_print_lines = [
mock.call.write("Setting1=NewFoo"), mock.call.write('\n'),
mock.call.write(";Setting2=Bar"), mock.call.write('\n'),
mock.call.write("Setting3=Baz"), mock.call.write('\n'),
mock.call.write("Setting4="), mock.call.write('\n'),
]
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, 'Setting1', 'NewFoo')
mock_stdout.assert_has_calls(expected_print_lines)
@mock.patch('os.path.isfile')
@mock.patch('fileinput.input')
@mock.patch('sys.stdout')
def test_ReplaceLineInFile_CommentedSettingMatch_SettingReplaced(self, mock_stdout, mock_input, mock_isfile):
mock_isfile.return_value = True
mock_input.return_value = MockDocumentInput(self.mock_file_content)
expected_print_lines = [
mock.call.write("Setting1=Foo"), mock.call.write('\n'),
mock.call.write("Setting2=NewBar"), mock.call.write('\n'),
mock.call.write("Setting3=Baz"), mock.call.write('\n'),
mock.call.write("Setting4="), mock.call.write('\n'),
]
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, 'Setting2', 'NewBar')
mock_stdout.assert_has_calls(expected_print_lines)
@mock.patch('os.path.isfile')
@mock.patch('fileinput.input')
@mock.patch('sys.stdout')
def test_ReplaceLineInFile_EmptySettingMatch_SettingReplaced(self, mock_stdout, mock_input, mock_isfile):
mock_isfile.return_value = True
mock_input.return_value = MockDocumentInput(self.mock_file_content)
expected_print_lines = [
mock.call.write("Setting1=Foo"), mock.call.write('\n'),
mock.call.write(";Setting2=Bar"), mock.call.write('\n'),
mock.call.write("Setting3=Baz"), mock.call.write('\n'),
mock.call.write("Setting4=NewContent"), mock.call.write('\n'),
]
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, 'Setting4', 'NewContent')
mock_stdout.assert_has_calls(expected_print_lines)
@mock.patch('os.path.isfile')
@mock.patch('fileinput.input')
@mock.patch('sys.stdout')
def test_ReplaceLineInFile_NoMatch_SettingAppended(self, mock_stdout, mock_input, mock_isfile):
mock_isfile.return_value = True
mock_input.return_value = MockDocumentInput(self.mock_file_content)
expected_print_lines = [
mock.call.write("Setting1=Foo"), mock.call.write('\n'),
mock.call.write(";Setting2=Bar"), mock.call.write('\n'),
mock.call.write("Setting3=Baz"), mock.call.write('\n'),
mock.call.write("Setting4="), mock.call.write('\n'),
]
ly_test_tools.lumberyard.settings._edit_text_settings_file(self.file_name, 'Setting5', 'NewSetting!')
mock_stdout.assert_has_calls(expected_print_lines)
@@ -0,0 +1,128 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools.lumberyard.shader_compiler
"""
import unittest.mock as mock
import pytest
import ly_test_tools._internal.managers.workspace
import ly_test_tools._internal.managers.abstract_resource_locator
import ly_test_tools.lumberyard.shader_compiler
pytestmark = pytest.mark.SUITE_smoke
mock_initial_path = "mock_initial_path"
mock_engine_root = "mock_engine_root"
mock_dev_path = "mock_dev_path"
mock_build_directory = 'mock_build_directory'
mock_project = 'mock_project'
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.abspath',
mock.MagicMock(return_value=mock_initial_path))
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
@mock.patch('ly_test_tools.lumberyard.asset_processor.logger.warning', mock.MagicMock())
class TestShaderCompiler(object):
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
def test_Init_MockWorkspace_MembersSetCorrectly(self, mock_workspace):
under_test = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(mock_workspace)
assert under_test._workspace == mock_workspace
assert under_test._sc_proc is None
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.Popen')
@mock.patch('ly_test_tools.WINDOWS', True)
def test_Start_NoneRunning_ProcessStarted(self, mock_popen, mock_workspace):
mock_shader_compiler_path = 'mock_shader_compiler_path'
mock_workspace.paths.get_shader_compiler_path.return_value = mock_shader_compiler_path
mock_popen.return_value = mock.MagicMock()
under_test = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(mock_workspace)
assert under_test._sc_proc is None
under_test.start()
assert under_test._sc_proc is not None
mock_popen.assert_called_once_with(['RunAs', '/trustlevel:0x20000', mock_shader_compiler_path])
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.Popen')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.MAC', True)
def test_Start_NotImplemented_ErrorRaised(self, mock_popen, mock_workspace):
under_test = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(mock_workspace)
assert under_test._sc_proc is None
with pytest.raises(NotImplementedError):
under_test.start()
assert under_test._sc_proc is None
mock_popen.assert_not_called()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('subprocess.Popen')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.logger.info')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.MAC', True)
def test_Start_AlreadyRunning_ProcessNotChanged(self, mock_logger, mock_popen, mock_workspace):
mock_shader_compiler_path = 'mock_shader_compiler_path'
mock_workspace.paths.get_shader_compiler_path.return_value = mock_shader_compiler_path
mock_popen.return_value = mock.MagicMock()
under_test = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(mock_workspace)
under_test._sc_proc = 'foo'
under_test.start()
assert under_test._sc_proc is not None
mock_popen.assert_not_called()
mock_logger.assert_called_once_with(
'Attempted to start shader compiler at the path: {0}, '
'but we already have one open!'.format(mock_shader_compiler_path))
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.process_utils.kill_processes_started_from')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.waiter.wait_for')
def test_Stop_AlreadyRunning_ProcessStopped(self, mock_wait, mock_kill, mock_workspace):
mock_shader_compiler_path = 'mock_shader_compiler_path'
mock_workspace.paths.get_shader_compiler_path.return_value = mock_shader_compiler_path
under_test = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(mock_workspace)
under_test._sc_proc = 'foo'
under_test.stop()
assert under_test._sc_proc is None
mock_kill.assert_called_once_with(mock_shader_compiler_path)
mock_wait.assert_called_once()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.process_utils.kill_processes_started_from')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.waiter.wait_for')
@mock.patch('ly_test_tools.lumberyard.shader_compiler.logger.info')
def test_Stop_NoneRunning_MessageLogged(self, mock_logger, mock_wait, mock_kill, mock_workspace):
mock_shader_compiler_path = 'mock_shader_compiler_path'
mock_workspace.paths.get_shader_compiler_path.return_value = mock_shader_compiler_path
under_test = ly_test_tools.lumberyard.shader_compiler.ShaderCompiler(mock_workspace)
under_test._sc_proc = None
under_test.stop()
assert under_test._sc_proc is None
mock_kill.assert_not_called()
mock_wait.assert_not_called()
mock_logger.assert_called_once_with(
'Attempted to stop shader compiler at the path: {0}, '
'but we do not have any open!'.format(mock_shader_compiler_path))
@@ -0,0 +1,105 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit tests for ly_test_tools._internal.pytest_plugin.terminal_report
"""
import os
import pytest
import unittest.mock as mock
import ly_test_tools._internal.pytest_plugin.terminal_report as terminal_report
pytestmark = pytest.mark.SUITE_smoke
class TestTerminalReport(object):
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.build_rerun_commands')
def test_AddCommands_MockCommands_CommandsAdded(self, mock_build_commands):
mock_build_commands.side_effect = lambda path, nodes: nodes
mock_reporter = mock.MagicMock()
header = 'This is a header'
test_path = 'Foo'
mock_node_ids = ['a', 'b']
terminal_report._add_commands(mock_reporter, header, test_path, mock_node_ids)
mock_reporter.write_line.assert_has_calls([
mock.call(header),
mock.call('a'),
mock.call('b')
])
@mock.patch('ly_test_tools._internal.pytest_plugin.failed_test_rerun_command.build_rerun_commands')
def test_AddCommands_NoCommands_ErrorWritten(self, mock_build_commands):
mock_reporter = mock.MagicMock()
header = 'This is a header'
test_path = 'Foo'
mock_node_ids = []
terminal_report._add_commands(mock_reporter, header, test_path, mock_node_ids)
calls = mock_reporter.write_line.mock_calls
mock_build_commands.assert_not_called()
assert calls[0] == mock.call(header)
assert 'Error' in calls[1][1][0]
@mock.patch('ly_test_tools._internal.pytest_plugin.terminal_report._add_commands')
def test_TerminalSummary_NoErrorsNoFailures_EmptyReport(self, mock_add_commands):
mock_report = mock.MagicMock()
mock_report.stats.get.return_value = []
terminal_report.pytest_terminal_summary(mock_report, 0)
mock_add_commands.assert_not_called()
mock_report.config.getoption.assert_not_called()
mock_report.section.assert_not_called()
@mock.patch('ly_test_tools._internal.pytest_plugin.terminal_report._add_commands')
def test_TerminalSummary_ErrorsAndFailures_SectionsAdded(self, mock_add_commands):
mock_report = mock.MagicMock()
mock_node = mock.MagicMock()
mock_node.nodeid = 'something'
mock_report.stats.get.return_value = [mock_node, mock_node]
terminal_report.pytest_terminal_summary(mock_report, 0)
assert len(mock_add_commands.mock_calls) == 2
mock_report.config.getoption.assert_called()
mock_report.section.assert_called_once()
@mock.patch('ly_test_tools._internal.pytest_plugin.terminal_report._add_commands', mock.MagicMock())
@mock.patch('os.path.basename')
def test_TerminalSummary_Failures_CallsWithBasename(self, mock_basename):
mock_report = mock.MagicMock()
mock_node = mock.MagicMock()
mock_base = 'something'
node_id = os.path.join('C:', mock_base)
mock_node.nodeid = node_id
mock_report.stats.get.side_effect = [[mock_node], []] # first item is failure list
terminal_report.pytest_terminal_summary(mock_report, 0)
mock_basename.assert_called_with(node_id)
@mock.patch('ly_test_tools._internal.pytest_plugin.terminal_report._add_commands', mock.MagicMock())
@mock.patch('os.path.basename')
def test_TerminalSummary_Errors_CallsWithBasename(self, mock_basename):
mock_report = mock.MagicMock()
mock_node = mock.MagicMock()
mock_base = 'something'
node_id = os.path.join('C:', mock_base)
mock_node.nodeid = node_id
mock_report.stats.get.side_effect = [[], [mock_node]] # second item is error list
terminal_report.pytest_terminal_summary(mock_report, 0)
mock_basename.assert_called_with(node_id)
@@ -0,0 +1,50 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
"""
import unittest.mock as mock
import unittest
import time
import pytest
import ly_test_tools.environment.waiter
pytestmark = pytest.mark.SUITE_smoke
@mock.patch('time.sleep', mock.MagicMock)
class TestWaitFor(unittest.TestCase):
def test_WaitForFunctionCall_GivenExceptionTimeoutExceeded_RaiseException(self):
input_func = mock.MagicMock()
input_func.return_value = False
with self.assertRaises(Exception):
ly_test_tools.environment.waiter.wait_for(input_func, .001, Exception, 0)
def test_WaitForFunctionCall_TimeoutExceeded_RaiseAssertionError(self):
input_func = mock.MagicMock()
input_func.return_value = False
with self.assertRaises(Exception):
ly_test_tools.environment.waiter.wait_for(input_func, .001, interval=0)
def test_WaitForFunctionCall_TimeoutExceeded_EnoughTime(self):
input_func = mock.MagicMock()
input_func.return_value = False
timeout_end = time.time() + 0.1
try:
ly_test_tools.environment.waiter.wait_for(input_func, 0.1, Exception, interval=0.01)
except Exception:
pass
# It should have taken at least 1/10 second
assert time.time() > timeout_end
@@ -0,0 +1,264 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for watchdog.py
"""
import unittest
import unittest.mock as mock
import pytest
import ly_test_tools.environment.watchdog as watchdog
pytestmark = pytest.mark.SUITE_smoke
def mock_bool_fn():
return
class TestWatchdog(unittest.TestCase):
@mock.patch('threading.Event')
@mock.patch('threading.Thread')
def test_Watchdog_Instantiated_CreatesEventAndThread(self, mock_thread, mock_event):
mock_watchdog = watchdog.Watchdog(mock_bool_fn)
mock_event.assert_called_once()
mock_thread.assert_called_once_with(target=mock_watchdog._watchdog, name=mock_watchdog.name)
@mock.patch('threading.Event', mock.MagicMock())
@mock.patch('threading.Thread', mock.MagicMock())
class TestWatchdogMethods(unittest.TestCase):
def setUp(self):
self.mock_watchdog = watchdog.Watchdog(mock_bool_fn)
@mock.patch('threading.Thread.start')
@mock.patch('threading.Event.clear')
def test_Start_Called_ClearsEventAndStartsThread(self, mock_clear, mock_start):
self.mock_watchdog.start()
mock_clear.assert_called_once()
mock_start.assert_called_once()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('threading.Event.set')
def test_Stop_Called_CallsEventSet(self, under_test):
self.mock_watchdog.stop()
under_test.assert_called_once()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('threading.Event.set', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.logging.Logger.error')
def test_Stop_NoCaughtFailures_NoRaiseOrError(self, mock_error_log):
self.mock_watchdog.caught_failure = False
try:
self.mock_watchdog.stop()
except watchdog.WatchdogError as e:
self.fail(f"Unexpected WatchdogError called. Error: {e}")
mock_error_log.assert_not_called()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('threading.Event.set', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.logging.Logger.error')
def test_Stop_CaughtFailuresAndRaisesOnCondition_RaisesWatchdogError(self, mock_error_log):
self.mock_watchdog.caught_failure = True
self.mock_watchdog._raise_on_condition = True
with pytest.raises(watchdog.WatchdogError):
self.mock_watchdog.stop()
mock_error_log.assert_not_called()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('threading.Event.set', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.logging.Logger.error')
def test_Stop_CaughtFailuresAndNotRaisesOnCondition_LogsError(self, mock_error_log):
self.mock_watchdog.caught_failure = True
self.mock_watchdog._raise_on_condition = False
try:
self.mock_watchdog.stop()
except watchdog.WatchdogError as e:
self.fail(f"Unexpected WatchdogError called. Error: {e}")
mock_error_log.assert_called_once()
@mock.patch('threading.Thread.join')
def test_Stop_Called_CallsJoin(self, under_test):
self.mock_watchdog.caught_failure = False
self.mock_watchdog.stop()
under_test.assert_called_once()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.Watchdog.is_alive')
@mock.patch('ly_test_tools.environment.watchdog.logging.Logger.error')
def test_Stop_ThreadIsAlive_LogsError(self, under_test, mock_is_alive):
mock_is_alive.return_value = True
self.mock_watchdog.stop()
under_test.assert_called_once()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('ly_test_tools.environment.watchdog.Watchdog.is_alive')
@mock.patch('ly_test_tools.environment.watchdog.logging.Logger.error')
def test_Stop_ThreadNotAlive_NoLogsError(self, under_test, mock_is_alive):
mock_is_alive.return_value = False
self.mock_watchdog.stop()
under_test.assert_not_called()
@mock.patch('threading.Thread.is_alive')
def test_IsAlive_Called_CallsIsAlive(self, under_test):
self.mock_watchdog.is_alive()
under_test.assert_called_once()
@mock.patch('threading.Event.wait')
def test_WatchdogRunner_ShutdownEventNotSet_CallsBoolFn(self, mock_event_wait):
mock_event_wait.side_effect = [False, True]
mock_bool_fn = mock.MagicMock()
mock_bool_fn.return_value = False
self.mock_watchdog._bool_fn = mock_bool_fn
self.mock_watchdog._watchdog()
self.mock_watchdog._bool_fn.assert_called_once()
assert self.mock_watchdog.caught_failure == False
def test_WatchdogRunner_ShutdownEventSet_NoCallsBoolFn(self):
self.mock_watchdog._shutdown.set()
mock_bool_fn = mock.MagicMock()
self.mock_watchdog._bool_fn = mock_bool_fn
under_test = self.mock_watchdog._watchdog()
assert under_test is None
self.mock_watchdog._bool_fn.assert_not_called()
@mock.patch('threading.Event.wait')
def test_WatchdogRunner_BoolFnReturnsTrue_SetsCaughtFailureToTrue(self, mock_event_wait):
mock_event_wait.side_effect = [False, True]
mock_bool_fn = mock.MagicMock()
mock_bool_fn.return_value = True
self.mock_watchdog._bool_fn = mock_bool_fn
self.mock_watchdog._watchdog()
assert self.mock_watchdog.caught_failure == True
class TestProcessUnresponsiveWatchdog(unittest.TestCase):
mock_process_id = 11111
mock_name = 'foo.exe'
mock_process_not_resp_call = \
'\r\n " \
"Image Name PID Session Name Session# Mem Usage\r\n" \
"========================= ======== ================ =========== ============\r\n" \
"foo.exe %d Console 1 0 K\r\n"' % mock_process_id
mock_process_resp_call = "INFO: No tasks are running which match the specified criteria.\r\n"
@mock.patch('psutil.Process', mock.MagicMock())
def setUp(self):
self.mock_watchdog = watchdog.ProcessUnresponsiveWatchdog(self.mock_process_id)
self.mock_watchdog._process_name = self.mock_name
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_ProcessNotResponding_ProcessResponsive_ReturnsFalse(self, mock_check_output):
mock_check_output.return_value = self.mock_process_resp_call
self.mock_watchdog._pid = self.mock_process_id
under_test = self.mock_watchdog._process_not_responding()
assert not under_test
assert self.mock_watchdog._calculated_timeout_point is None
@mock.patch('time.time')
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_ProcessNotResponding_ProcessUnresponsiveNoTimeout_ReturnsFalse(self, mock_check_output, mock_time):
mock_time.return_value = 1
mock_check_output.return_value = self.mock_process_not_resp_call
self.mock_watchdog._pid = self.mock_process_id
under_test = self.mock_watchdog._process_not_responding()
timeout_under_test = mock_time.return_value + self.mock_watchdog._unresponsive_timeout
assert not under_test
assert self.mock_watchdog._calculated_timeout_point == timeout_under_test
@mock.patch('time.time')
@mock.patch('ly_test_tools.environment.process_utils.check_output')
def test_ProcessNotResponding_ProcessUnresponsiveReachesTimeout_ReturnsTrue(self, mock_check_output, mock_time):
mock_time.return_value = 3
mock_check_output.return_value = self.mock_process_not_resp_call
self.mock_watchdog._pid = self.mock_process_id
self.mock_watchdog._calculated_timeout_point = 2
under_test = self.mock_watchdog._process_not_responding()
assert under_test
def test_GetPid_Called_ReturnsAttribute(self):
self.mock_watchdog._pid = self.mock_process_id
assert self.mock_watchdog.get_pid() == self.mock_watchdog._pid
class TestCrashLogWatchdog(unittest.TestCase):
mock_log_path = 'C:/foo'
@mock.patch('os.path.exists')
def test_CrashExists_Called_CallsOsPathExists(self, under_test):
under_test.side_effect = [False, mock.DEFAULT]
mock_watchdog = watchdog.CrashLogWatchdog(self.mock_log_path)
mock_watchdog._bool_fn()
under_test.assert_called_with(self.mock_log_path)
@mock.patch('os.path.exists')
@mock.patch('os.remove')
def test_CrashLogWatchdog_LogsExist_ClearsExistingLogs(self, under_test, mock_exists):
mock_exists.return_value = True
mock_watchdog = watchdog.CrashLogWatchdog(self.mock_log_path)
under_test.assert_called_once_with(self.mock_log_path)
@mock.patch('os.path.exists')
@mock.patch('os.remove')
def test_CrashLogWatchdog_LogsNotExist_NoClearsExistingLogs(self, under_test, mock_exists):
mock_exists.return_value = False
mock_watchdog = watchdog.CrashLogWatchdog(self.mock_log_path)
under_test.assert_not_called()
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('builtins.print')
@mock.patch('builtins.open')
@mock.patch('os.path.exists')
def test_CrashLogWatchdogStop_LogsExists_OpensLogAndPrints(self, mock_exists, mock_open, mock_print):
mock_exists.side_effect = [False, True]
mock_watchdog = watchdog.CrashLogWatchdog(self.mock_log_path)
mock_watchdog.caught_failure = True
mock_watchdog._raise_on_condition = False
mock_watchdog.stop()
mock_open.assert_called_once_with(mock_watchdog._log_path, "r")
assert mock_print.called
@mock.patch('threading.Thread.join', mock.MagicMock())
@mock.patch('builtins.print')
@mock.patch('builtins.open')
@mock.patch('os.path.exists')
def test_CrashLogWatchdogStop_LogsNoExists_NoOpensAndNoPrintsLog(self, mock_exists, mock_open, mock_print):
mock_exists.return_value = False
mock_watchdog = watchdog.CrashLogWatchdog(self.mock_log_path)
mock_watchdog.caught_failure = False
mock_watchdog._raise_on_condition = False
mock_watchdog.stop()
assert not mock_open.called
assert not mock_print.called
@@ -0,0 +1,188 @@
"""
All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
its licensors.
For complete copyright and license terms please see the LICENSE at the root of this
distribution (the "License"). All use of this software is governed by the License,
or, if provided, by the license below or the license accompanying this file. Do not
remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Unit Tests for workspace module
"""
import unittest.mock as mock
import pytest
import unittest
import ly_test_tools._internal.managers.workspace
pytestmark = pytest.mark.SUITE_smoke
mock_initial_path = "mock_initial_path"
mock_engine_root = "mock_engine_root"
mock_dev_path = "mock_dev_path"
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator.os.path.abspath',
mock.MagicMock(return_value=mock_initial_path))
@mock.patch('ly_test_tools._internal.managers.abstract_resource_locator._find_engine_root',
mock.MagicMock(return_value=(mock_engine_root, mock_dev_path)))
class MockedWorkspaceManager(ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager):
def __init__(
self,
resource_locator=mock.MagicMock(),
project=mock.MagicMock(),
tmp_path=mock.MagicMock(),
output_path=mock.MagicMock()
):
super(MockedWorkspaceManager, self).__init__(
resource_locator=resource_locator,
project=project,
tmp_path=tmp_path,
output_path=output_path
)
def setup(self):
super(MockedWorkspaceManager, self).setup()
def run_setup_assistant(self):
pass
class TestWorkspaceManager:
def test_Init_NoInheritanceAbstractWorkspaceManager_RaisesTypeError(self):
mock_resource_locator = mock.MagicMock()
with pytest.raises(TypeError):
ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager(resource_locator=mock_resource_locator)
@mock.patch('ly_test_tools._internal.managers.artifact_manager.NullArtifactManager.__init__')
@mock.patch('tempfile.mkdtemp')
def test_Init_TmpPathIsNone_TmpPathIsCreated(self, under_test, mock_null_artifact_manager):
mock_null_artifact_manager.return_value = None
mock_workspace = MockedWorkspaceManager(tmp_path=None)
under_test.assert_called_once()
@mock.patch('os.path.exists', mock.MagicMock())
@mock.patch('tempfile.mkdtemp', mock.MagicMock())
def test_Init_LogsPathIsNone_LogsPathIsSetToDefault(self):
dummy_path = 'mockTestResults'
mock_resource_locator = mock.MagicMock()
mock_resource_locator.test_results.return_value = dummy_path
mock_workspace = MockedWorkspaceManager(resource_locator=mock_resource_locator, output_path=None)
assert mock_workspace.output_path.startswith(dummy_path)
class TestSetup(unittest.TestCase):
def setUp(self):
self.mock_workspace = MockedWorkspaceManager()
@mock.patch('os.makedirs')
@mock.patch('os.path.exists')
def test_Setup_TmpPathExists_CallsMakeDirs(self, mock_path_exists, mock_makedirs):
self.mock_workspace.tmp_path = 'mock_tmp_path'
mock_path_exists.side_effect = [False, True, True] # ArtifactManager.__init__() calls os.path.exists()
self.mock_workspace._custom_output_path = True
self.mock_workspace.setup()
mock_makedirs.assert_called_once_with(self.mock_workspace.tmp_path)
@mock.patch('os.makedirs')
@mock.patch('os.path.exists')
def test_Setup_TmpPathNotExists_NoCallsMakeDirs(self, mock_path_exists, mock_makedirs):
mock_path_exists.return_value = True
self.mock_workspace.tmp_path = None
self.mock_workspace._custom_output_path = True
self.mock_workspace.setup()
assert not mock_makedirs.called
@mock.patch('os.makedirs')
@mock.patch('os.path.exists')
def test_Setup_LogPathExists_NoCallsMakeDirs(self, mock_path_exists, mock_makedirs):
mock_path_exists.return_value = True
self.mock_workspace.tmp_path = None
self.mock_workspace.setup()
mock_makedirs.assert_not_called()
@mock.patch('os.makedirs')
@mock.patch('os.path.exists')
def test_Setup_LogPathNotExists_CallsMakeDirs(self, mock_path_exists, mock_makedirs):
mock_path_exists.return_value = False
self.mock_workspace.tmp_path = None
self.mock_workspace.output_path = 'mock_output_path'
self.mock_workspace.setup()
mock_makedirs.assert_called_with(self.mock_workspace.output_path)
assert mock_makedirs.call_count == 2 # ArtifactManager.__init__() calls os.path.exists()
class TestTeardown(unittest.TestCase):
def setUp(self):
self.mock_workspace = MockedWorkspaceManager()
@mock.patch('os.chdir', mock.MagicMock())
@mock.patch('ly_test_tools.environment.file_system.delete')
def test_Teardown_TmpPathNotNone_DeletesTmpPath(self, mock_delete):
self.mock_workspace.tmp_path = 'tmp_path'
self.mock_workspace.teardown()
mock_delete.assert_called_once_with([self.mock_workspace.tmp_path], True, True)
@mock.patch('ly_test_tools.environment.file_system.delete', mock.MagicMock())
@mock.patch('os.chdir')
def test_Teardown_CwdModified_RevertsCwdToDefault(self, mock_chdir):
self.mock_workspace.tmp_path = 'tmp_path'
self.mock_workspace.teardown()
mock_chdir.assert_called_once_with(self.mock_workspace._original_cwd)
class TestClearCache(unittest.TestCase):
def setUp(self):
self.mock_workspace = MockedWorkspaceManager()
@mock.patch('ly_test_tools.environment.file_system.delete')
@mock.patch('os.path.exists')
def test_ClearCache_CacheExists_CacheIsDeleted(self, mock_path_exists, mock_delete):
mock_path_exists.return_value = True
self.mock_workspace.clear_cache()
mock_delete.assert_called_once_with([self.mock_workspace.paths.cache()], True, True)
@mock.patch('ly_test_tools.environment.file_system.delete')
@mock.patch('os.path.exists')
def test_ClearCache_CacheNotExists_CacheNotDeleted(self, mock_path_exists, mock_delete):
mock_path_exists.return_value = False
self.mock_workspace.clear_cache()
mock_delete.assert_not_called()
class TestClearBin(unittest.TestCase):
def setUp(self):
self.mock_workspace = MockedWorkspaceManager()
@mock.patch('ly_test_tools.environment.file_system.delete')
@mock.patch('os.path.exists')
def test_ClearBin_BinExists_BinIsDeleted(self, mock_path_exists, mock_delete):
mock_path_exists.return_value = True
self.mock_workspace.clear_bin()
mock_delete.assert_called_once_with([self.mock_workspace.paths.build_directory()], True, True)
@mock.patch('ly_test_tools.environment.file_system.delete')
@mock.patch('os.path.exists')
def test_ClearBin_BinNotExists_BinNotDeleted(self, mock_path_exists, mock_delete):
mock_path_exists.return_value = False
self.mock_workspace.clear_bin()
mock_delete.assert_not_called()