Platform manager and sanity tests for Linux

Signed-off-by: sweeneys <sweeneys@amazon.com>
This commit is contained in:
sweeneys
2021-10-08 15:36:52 -07:00
parent d3f6898ad6
commit 2034cd3053
9 changed files with 149 additions and 110 deletions
+4 -2
View File
@@ -16,7 +16,7 @@ ALL_PLATFORM_OPTIONS = ['android', 'ios', 'linux', 'mac', 'windows']
ALL_LAUNCHER_OPTIONS = ['android', 'base', 'linux', 'mac', 'windows', 'windows_editor', 'windows_dedicated', 'windows_generic']
ANDROID = False
IOS = False # Not implemented - see SPEC-2505
LINUX = sys.platform.startswith('linux') # Not implemented - see SPEC-2501
LINUX = sys.platform.startswith('linux')
MAC = sys.platform.startswith('darwin')
WINDOWS = sys.platform.startswith('win')
@@ -54,9 +54,11 @@ elif LINUX:
HOST_OS_PLATFORM = 'linux'
HOST_OS_EDITOR = 'linux_editor'
HOST_OS_DEDICATED_SERVER = 'linux_dedicated'
from ly_test_tools.launchers.platforms.linux.launcher import (LinuxLauncher, LinuxEditor, DedicatedLinuxLauncher)
HOST_OS_GENERIC_EXECUTABLE = 'linux_generic'
from ly_test_tools.launchers.platforms.linux.launcher import (LinuxLauncher, LinuxEditor, DedicatedLinuxLauncher, LinuxGenericLauncher)
LAUNCHERS['linux'] = LinuxLauncher
LAUNCHERS['linux_editor'] = LinuxEditor
LAUNCHERS['linux_dedicated'] = DedicatedLinuxLauncher
LAUNCHERS['linux_generic'] = LinuxGenericLauncher
else:
logger.warning(f'WARNING: LyTestTools only supports Windows, Mac, and Linux. Unexpectedly detected HOST_OS_PLATFORM: "{HOST_OS_PLATFORM}".')
@@ -265,7 +265,7 @@ class AbstractResourceLocator(object):
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')
return os.path.join(self.ap_log_dir(), 'AP_GUI.log')
def project_cache(self):
"""
@@ -23,9 +23,6 @@ class _LinuxResourceManager(AbstractResourceLocator):
"""
Override for locating resources in a Linux operating system running LyTestTools.
"""
def __init__(self, build_directory: str, project: str):
pass
def platform_config_file(self):
"""
:return: path to the platform config file
@@ -465,3 +465,17 @@ def close_windows_process(pid, timeout=20, raise_on_missing=False):
# Wait for asyncronous termination
waiter.wait_for(lambda: pid not in psutil.pids(), timeout=timeout,
exc=TimeoutError(f"Process {pid} never terminated"))
def get_display_env():
"""
Fetches environment variables with an appropriate display (monitor) configured,
useful for subprocess calls to UI applications
:return: A dictionary containing environment variables (per os.environ)
"""
env = os.environ.copy()
if not ly_test_tools.WINDOWS:
if 'DISPLAY' not in env.keys():
# assume Display 1 is available in another session
env['DISPLAY'] = ':1'
return env
@@ -13,6 +13,7 @@ import subprocess
import ly_test_tools.environment.waiter
import ly_test_tools.launchers.exceptions
import ly_test_tools.environment.process_utils as process_utils
from ly_test_tools.launchers.platforms.base import Launcher
from ly_test_tools.launchers.exceptions import TeardownError, ProcessNotStartedError
@@ -68,7 +69,7 @@ class LinuxLauncher(Launcher):
"""
command = [self.binary_path()] + self.args
self._tmpout = TemporaryFile()
self._proc = subprocess.Popen(command, stdout=self._tmpout, stderr=self._tmpout, universal_newlines=True)
self._proc = subprocess.Popen(command, stdout=self._tmpout, stderr=self._tmpout, universal_newlines=True, env=process_utils.get_display_env())
log.debug(f"Started Linux Launcher with command: {command}")
def get_output(self, encoding="utf-8"):
@@ -222,3 +223,28 @@ class LinuxEditor(LinuxLauncher):
"""
assert self.workspace.project is not None
return os.path.join(self.workspace.paths.build_directory(), "Editor")
class LinuxGenericLauncher(LinuxLauncher):
def __init__(self, build, exe_file_name, args=None):
super(LinuxLauncher, self).__init__(build, args)
self.exe_file_name = exe_file_name
self.expected_executable_path = os.path.join(
self.workspace.paths.build_directory(), f"{self.exe_file_name}")
if not os.path.exists(self.expected_executable_path):
raise ProcessNotStartedError(
f"Unable to locate executable '{self.exe_file_name}' "
f"in path: '{self.expected_executable_path}'")
def binary_path(self):
"""
Return full path to the executable file for this build's configuration and project
Relies on the build_directory() in self.workspace.paths to be accurate
:return: full path to the given exe file
"""
assert self.workspace.project is not None, (
'Project cannot be NoneType - please specify a project name string.')
return self.expected_executable_path
@@ -7,21 +7,22 @@ SPDX-License-Identifier: Apache-2.0 OR MIT
A class to control functionality of Lumberyard's asset processor.
The class manages a workspace's asset processor and asset configurations.
"""
import os
import datetime
import logging
import subprocess
import socket
import time
import tempfile
import shutil
import stat
from typing import List, Tuple
import os
import psutil
import shutil
import socket
import stat
import subprocess
import tempfile
import time
from typing import List, Tuple
import ly_test_tools
import ly_test_tools.environment.waiter as waiter
import ly_test_tools.environment.file_system as file_system
import ly_test_tools.environment.process_utils as process_utils
import ly_test_tools.environment.waiter as waiter
import ly_test_tools.o3de.pipeline_utils as utils
from ly_test_tools.o3de.ap_log_parser import APLogParser
@@ -177,24 +178,26 @@ class AssetProcessor(object):
"""
Read the a port chosen by AP from the log
"""
start_time = time.time()
read_port_timeout = 10
while (time.time() - start_time) < read_port_timeout:
port = None
def _get_port_from_log():
nonlocal port
if not os.path.exists(self._workspace.paths.ap_gui_log()):
logger.debug(f"Log at {self._workspace.paths.ap_gui_log()} doesn't exist, sleeping")
else:
log = APLogParser(self._workspace.paths.ap_gui_log())
if len(log.runs):
try:
port = log.runs[-1][port_type]
if port:
logger.info(f"Read port type {port_type} : {port}")
return port
except Exception: # intentionally broad
pass
time.sleep(1)
logger.warning(f"Failed to read port type {port_type}")
return 0
return False
log = APLogParser(self._workspace.paths.ap_gui_log())
if len(log.runs):
try:
port = log.runs[-1][port_type]
logger.debug(f"Read port type {port_type} : {port}")
return True
except Exception as ex: # intentionally broad
logger.debug("Failed to read port from file", exc_info=ex)
return False
err = AssetProcessorError(f"Failed to read port type {port_type} from {self._workspace.paths.ap_gui_log()}")
waiter.wait_for(_get_port_from_log, timeout=10, exc=err)
return port
def set_control_connection(self, connection):
self._control_connection = connection
@@ -206,13 +209,8 @@ class AssetProcessor(object):
"""
if not self._control_connection:
control_timeout = 60
try:
return self.connect_socket("Control Connection", self.read_control_port,
set_port_method=self.set_control_connection, timeout=control_timeout)
except AssetProcessorError as e:
# We dont want a failure of our test socket connection to fail the entire test automatically.
logger.error(f"Failed to connect control socket with error {e}")
pass
return self.connect_socket("Control Connection", self.read_control_port,
set_port_method=self.set_control_connection, timeout=control_timeout)
return True, None
def using_temp_workspace(self):
@@ -227,34 +225,40 @@ class AssetProcessor(object):
:param set_port_method: If set, method to call with the established connection
:param timeout: Max seconds to attempt connection for
"""
connection_timeout = timeout
connect_port = read_port_method()
logger.debug(f"Waiting for connection to AP {port_name}: {host}:{connect_port}, "
f"{connection_timeout} seconds remaining")
start_time = time.time()
while (time.time() - start_time) < connection_timeout:
logger.debug(f"Attempting to for connect to AP {port_name}: {host}:{connect_port} for {timeout} seconds")
def _attempt_connection():
nonlocal connect_port
if self._ap_proc.poll() is not None:
raise AssetProcessorError(f"Asset processor exited early with errorcode: {self._ap_proc.returncode}")
connection_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
connection_socket.settimeout(10.0)
connection_socket.settimeout(timeout)
try:
connection_socket.connect((host, connect_port))
logger.debug(f"Connection to AP {port_name} was successful")
if set_port_method is not None:
set_port_method(connection_socket)
return True, None
except Exception: # Purposefully broad
# Short delay to prevent immediate failure due to slower starting applications such as debug builds
time.sleep(0.01)
return True
except Exception as ex: # Purposefully broad
logger.debug(f"Failed to connect to {host}:{connect_port}", exc_info=ex)
if not connect_port or not self.using_temp_workspace():
# If we're not using a temp workspace with a fresh log it's possible we're reading a port from
# a previous run and the log just hasn't written yet, we need to keep checking the log for a new
# port to use
new_connect_port = read_port_method()
if new_connect_port != connect_port:
logger.debug(
f"Read new connect port for {port_name}: {host}:{new_connect_port}")
connect_port = new_connect_port
raise AssetProcessorError(f"Could not connect to AP {port_name}")
try:
new_connect_port = read_port_method()
if new_connect_port != connect_port:
logger.debug(f"Found new connect port for {port_name}: {host}:{new_connect_port}")
connect_port = new_connect_port
except Exception as read_exception: # Purposefully broad
logger.debug(f"Failed to read port data", exc_info=read_exception)
return False
err = AssetProcessorError(f"Could not connect to AP {port_name} on {host}:{connect_port}")
waiter.wait_for(_attempt_connection, timeout=timeout, exc=err)
return True, None
def stop(self, timeout=60):
"""
@@ -436,15 +440,14 @@ class AssetProcessor(object):
extra_params=None, add_gem_scan_folders=None, add_config_scan_folders=None, decode=True,
expect_failure=False, quitonidle=False, connect_to_ap=False, accept_input=True, run_until_idle=True,
scan_folder_pattern=None):
ap_path = self._workspace.paths.asset_processor()
ap_path = os.path.abspath(self._workspace.paths.asset_processor())
ap_exe_path = os.path.dirname(ap_path)
extra_gui_params = []
if quitonidle:
extra_gui_params.append("--quitonidle")
if accept_input:
extra_gui_params.append("--acceptInput")
ap_exe_path = os.path.dirname(self._workspace.paths.asset_processor())
logger.info("Starting asset processor")
if self.process_exists():
logger.error("Asset processor already started. Stop first")
@@ -483,20 +486,30 @@ class AssetProcessor(object):
logger.warning(f"Cannot capture output when leaving AP connection open.")
logger.info(f"Launching AP with command: {command}")
self._ap_proc = subprocess.Popen(command, cwd=ap_exe_path)
try:
self._ap_proc = subprocess.Popen(command, cwd=ap_exe_path, env=process_utils.get_display_env())
if accept_input and not quitonidle:
self.connect_control()
if accept_input:
self.connect_control()
if connect_to_ap:
self.connect_listen()
if connect_to_ap:
self.connect_listen()
if quitonidle:
waiter.wait_for(lambda: not self.process_exists(), timeout=timeout)
elif run_until_idle and accept_input:
if not self.wait_for_idle():
return False, None
return True, None
if quitonidle:
waiter.wait_for(lambda: not self.process_exists(), timeout=timeout,
exc=AssetProcessorError(f"Failed to quit on idle within {timeout} seconds"))
elif run_until_idle and accept_input:
if not self.wait_for_idle():
return False, None
return True, None
except BaseException as be: # purposefully broad
logger.exception("Exception while starting Asset Processor", be)
# clean up to avoid leaking open AP process to future tests
try:
self._ap_proc.kill()
except Exception as ex:
logger.exception("Ignoring exception while trying to terminate Asset Processor", ex)
raise # raise whatever prompted us to clean up
def connect_listen(self, timeout=DEFAULT_TIMEOUT_SECONDS):
# Wait for the AP we launched to be ready to accept a connection
@@ -574,21 +587,17 @@ class AssetProcessor(object):
expect_failure=False):
"""
In case of a timeout, the asset processor and associated processes are killed and the function returns False.
:param timeout: seconds to wait before aborting
:param capture_output = Capture output which will be returned in the second of the return pair
:param decode: decode byte strings from captured output to utf-8
:param expect_failure: asset processing is expected to fail, so don't error on a failure, and assert on no failure.
"""
logger.info(f"Launching AP with command: {command}")
start = datetime.datetime.now()
try:
duration = datetime.timedelta(seconds=timeout)
except TypeError:
logger.warning("Cannot set timeout value of '{}' seconds, defaulting to {} hours".format(
timeout, DEFAULT_TIMEOUT_HOURS))
duration = datetime.timedelta(hours=DEFAULT_TIMEOUT_HOURS)
timeout = duration.total_seconds()
start = time.time()
if type(timeout) not in [int, float] or timeout < 1:
logger.warning(f"Invalid timeout {timeout} - defaulting to {DEFAULT_TIMEOUT_SECONDS} seconds")
timeout = DEFAULT_TIMEOUT_SECONDS
run_result = subprocess.run(command, close_fds=True, timeout=timeout, capture_output=capture_output)
output_list = None
@@ -609,8 +618,7 @@ class AssetProcessor(object):
elif expect_failure:
logger.error(f"{command} was expected to fail, but instead ran without failure.")
return True, output_list
logger.info(
f"{command} completed successfully in {(datetime.datetime.now() - start).seconds} seconds")
logger.info(f"{command} completed successfully in {time.time() - start} seconds")
return True, output_list
def set_failure_log_folder(self, log_root):
@@ -743,14 +751,14 @@ class AssetProcessor(object):
:return: Absolute path of added scan folder
"""
if os.path.isabs(folder_name):
if not folder_name in self._override_scan_folders:
if folder_name not in self._override_scan_folders:
self._override_scan_folders.append(folder_name)
logger.info(f'Adding override scan folder {folder_name}')
return folder_name
else:
if not self._temp_asset_root:
logger.warning(f"Can't create scan folder, no temporary asset workspace has been created")
return
logger.warning(f"Can not create scan folder, no temporary asset workspace has been created")
return ""
scan_folder = os.path.join(self._temp_asset_root if self._temp_asset_root else self._workspace.paths.engine_root(),
folder_name)
if not os.path.isdir(scan_folder):
@@ -802,9 +810,9 @@ class AssetProcessor(object):
if not use_current_root:
self.create_temp_asset_root()
test_asset_root = os.path.join(self._temp_asset_root, self._workspace.project if relative_asset_root is None
else relative_asset_root)
else relative_asset_root)
test_folder = os.path.join(test_asset_root, function_name if existing_function_name is None
else existing_function_name)
else existing_function_name)
if not os.path.isdir(test_folder):
os.makedirs(test_folder)
if add_scan_folder:
@@ -9,8 +9,7 @@ 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
import ly_test_tools.environment.process_utils as process_utils
logger = logging.getLogger(__name__)
@@ -23,7 +22,7 @@ def start_asset_processor(bin_dir):
:return: A subprocess.Popen object for the AssetProcessor process.
"""
os.chdir(bin_dir)
asset_processor = subprocess.Popen(['AssetProcessor.exe'])
asset_processor = subprocess.Popen(['AssetProcessor'], env=process_utils.get_display_env())
return_code = asset_processor.poll()
if return_code is not None and return_code != 0:
@@ -40,11 +39,9 @@ def kill_asset_processor():
: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)
kill_processes_named('Lua Editor', ignore_extensions=True)
process_utils.kill_processes_named('AssetProcessor_tmp', ignore_extensions=True)
process_utils.kill_processes_named('AssetProcessor', ignore_extensions=True)
process_utils.kill_processes_named('AssetProcessorBatch', ignore_extensions=True)
process_utils.kill_processes_named('AssetBuilder', ignore_extensions=True)
process_utils.kill_processes_named('rc', ignore_extensions=True)
process_utils.kill_processes_named('Lua Editor', ignore_extensions=True)
@@ -58,12 +58,11 @@ class TestAutomatedTestingProject(object):
# Call the game client executable
with launcher.start():
# Wait for the process to exist
waiter.wait_for(lambda: process_utils.process_exists(f"{project}.GameLauncher.exe", ignore_extensions=True))
waiter.wait_for(lambda: process_utils.process_exists(f"{project}.GameLauncher", ignore_extensions=True))
finally:
# Clean up processes after the test is finished
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):
"""
The `test_StartEditor_Sanity` test function is similar to the previous example with minor adjustments. A
@@ -58,10 +58,8 @@ class TestAssetProcessor(object):
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',
f'--regset="/Amazon/AzCore/Bootstrap/project_path={mock_project_path}"',
'--logDir', under_test.log_root(),
'--acceptInput', '--platforms', 'bar'], cwd=os.path.dirname(mock_ap_path))
mock_popen.assert_called_once()
assert '--zeroAnalysisMode' in mock_popen.call_args[0][0]
mock_connect.assert_called()
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')
@@ -114,7 +112,7 @@ class TestAssetProcessor(object):
assert result
mock_run.assert_called_once_with([apb_path,
f'--regset="/Amazon/AzCore/Bootstrap/project_path={mock_project_path}"',
f'--regset="/Amazon/AzCore/Bootstrap/project_path={mock_project_path}"',
'--logDir', under_test.log_root()],
close_fds=True, capture_output=False,
timeout=1)
@@ -150,10 +148,8 @@ class TestAssetProcessor(object):
result, _ = under_test.batch_process(None, False)
assert not result
mock_run.assert_called_once_with([apb_path,
f'--regset="/Amazon/AzCore/Bootstrap/project_path={mock_project_path}"',
'--logDir', under_test.log_root()],
close_fds=True, capture_output=False, timeout=28800.0)
mock_run.assert_called_once()
assert f'--regset="/Amazon/AzCore/Bootstrap/project_path={mock_project_path}"' in mock_run.call_args[0][0]
@mock.patch('ly_test_tools._internal.managers.workspace.AbstractWorkspaceManager')