From f77b1e871dd5a3bc133b08d7d11cd91430136bfd Mon Sep 17 00:00:00 2001 From: Sean Kim Date: Thu, 12 Jun 2025 20:27:38 +0200 Subject: [PATCH] Tests: Add UI tests that verify loading all default workspaces While individual modes have UI tests related to undo, this new set of tests in this new file is intended to be a set of very broad sanity tests that catch the most egregious errors that cause crashing on start up, whether due to python errors, UI rendering issues, or otherwise. Running these tests takes approximately 4 seconds currently as it adds and verifies the loading of each of the workspaces available "out of the box" to a blender user. Pull Request: https://projects.blender.org/blender/blender/pulls/139318 --- tests/python/CMakeLists.txt | 69 ++++---- tests/python/ui_simulate/run.py | 11 +- tests/python/ui_simulate/test_workspace.py | 174 +++++++++++++++++++++ tests/utils/blender_headless.py | 89 ++++++++--- 4 files changed, 277 insertions(+), 66 deletions(-) create mode 100644 tests/python/ui_simulate/test_workspace.py diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index fa7448b1679..0375fbdec3b 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -73,8 +73,8 @@ function(add_blender_test_io testname) endfunction() if(WITH_UI_TESTS) + set(_blender_headless_env_vars "BLENDER_BIN=${TEST_BLENDER_EXE}") if(WITH_UI_TESTS_HEADLESS) - set(_blender_headless_env_vars "BLENDER_BIN=${TEST_BLENDER_EXE}") # Currently only WAYLAND is supported, support for others may be added later. # In this case none of the WESTON environment variables will be used. @@ -104,43 +104,29 @@ if(WITH_UI_TESTS) ) endif() endif() - - function(add_blender_test_ui testname) - # Remove `--background` so headless execution uses a GUI - # (within a headless graphical environment). - set(EXE_PARAMS ${TEST_BLENDER_EXE_PARAMS}) - list(REMOVE_ITEM EXE_PARAMS --background) - add_blender_test_impl( - "${testname}" - "${_blender_headless_env_vars}" - "${TEST_PYTHON_EXE}" - "${CMAKE_SOURCE_DIR}/tests/utils/blender_headless.py" - # NOTE: attempting to maximize the window causes problems with a headless `weston`, - # while this could be investigated, use windowed mode instead. - # Use a window size that balances software GPU rendering with enough room to use the UI. - --factory-startup - -p 0 0 800 600 - "${EXE_PARAMS}" - "${ARGN}" - ) - endfunction() else() - function(add_blender_test_ui testname) - # Remove `--background` - set(EXE_PARAMS ${TEST_BLENDER_EXE_PARAMS}) - list(REMOVE_ITEM EXE_PARAMS --background) - - add_blender_test_impl( - "${testname}" - "" - "${TEST_BLENDER_EXE}" - --factory-startup - -p 0 0 800 600 - ${EXE_PARAMS} - ${ARGN} - ) - endfunction() + list(APPEND _blender_headless_env_vars + "PASS_THROUGH=1" + ) endif() + function(add_blender_test_ui testname) + # Remove `--background`a + set(EXE_PARAMS ${TEST_BLENDER_EXE_PARAMS}) + list(REMOVE_ITEM EXE_PARAMS --background) + add_blender_test_impl( + "${testname}" + "${_blender_headless_env_vars}" + "${TEST_PYTHON_EXE}" + "${CMAKE_SOURCE_DIR}/tests/utils/blender_headless.py" + # NOTE: attempting to maximize the window causes problems with a headless `weston`, + # while this could be investigated, use windowed mode instead. + # Use a window size that balances software GPU rendering with enough room to use the UI. + --factory-startup + -p 0 0 800 600 + "${EXE_PARAMS}" + "${ARGN}" + ) + endfunction() endif() # Run Python script outside Blender. @@ -1298,7 +1284,12 @@ if(WITH_UI_TESTS) # This could be generated with: # `"${TEST_PYTHON_EXE}" "${CMAKE_CURRENT_LIST_DIR}/ui_simulate/run.py" --list-tests` # list explicitly so changes bisecting/updated are sure to re-run CMake. - set(_undo_tests + set(_ui_tests + test_workspace.sanity_check_general + test_workspace.sanity_check_2d_animation + test_workspace.sanity_check_sculpting + test_workspace.sanity_check_vfx + test_workspace.sanity_check_video_editing test_undo.text_editor_edit_mode_mix test_undo.text_editor_simple test_undo.view3d_edit_mode_multi_window @@ -1316,7 +1307,7 @@ if(WITH_UI_TESTS) test_undo.view3d_texture_paint_complex test_undo.view3d_texture_paint_simple ) - foreach(ui_test ${_undo_tests}) + foreach(ui_test ${_ui_tests}) add_blender_test_ui( "ui_${ui_test}" --enable-event-simulate @@ -1325,7 +1316,7 @@ if(WITH_UI_TESTS) --tests "${ui_test}" ) endforeach() - unset(_undo_tests) + unset(_ui_tests) endif() diff --git a/tests/python/ui_simulate/run.py b/tests/python/ui_simulate/run.py index e6eb031d755..af37223d6fb 100755 --- a/tests/python/ui_simulate/run.py +++ b/tests/python/ui_simulate/run.py @@ -23,6 +23,7 @@ For an editor to follow the tests: import os import sys +import tempfile def create_parser(): @@ -137,7 +138,7 @@ def _process_test_id_fn(env, args, test_id): return test_id, callproc.returncode == 0 -def main(): +def run(empty_user_dir): directory = os.path.dirname(__file__) if "--list-tests" in sys.argv: list_tests(directory) @@ -164,6 +165,7 @@ def main(): env = os.environ.copy() env.update({ "LSAN_OPTIONS": "exitcode=0", + "BLENDER_USER_RESOURCES": empty_user_dir, }) # We could support multiple tests per Blender session. @@ -188,6 +190,13 @@ def main(): for test_id, ok in results: print("OK: " if ok else "FAIL:", test_id) + return 0 + + +def main(): + with tempfile.TemporaryDirectory() as empty_user_dir: + sys.exit(run(empty_user_dir)) + if __name__ == "__main__": main() diff --git a/tests/python/ui_simulate/test_workspace.py b/tests/python/ui_simulate/test_workspace.py new file mode 100644 index 00000000000..a63305915d6 --- /dev/null +++ b/tests/python/ui_simulate/test_workspace.py @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: 2025 Blender Authors +# +# SPDX-License-Identifier: GPL-2.0-or-later + +""" +This file does not run anything, it's methods are accessed for tests by: ``run.py``. +""" + + +def _test_window(windows_exclude=None): + import bpy + wm = bpy.data.window_managers[0] + if windows_exclude is None: + return wm.windows[0] + for window in wm.windows: + if window not in windows_exclude: + return window + return None + + +def _test_vars(window): + import unittest + from modules.easy_keys import EventGenerate + return ( + EventGenerate(window), + unittest.TestCase(), + ) + + +def _call_by_name(e, text: str): + yield e.f3() + yield e.text(text) + yield e.ret() + + +def _call_menu(e, text: str): + yield e.f3() + yield e.text_unicode(text.replace(" -> ", " \u25b8 ")) + yield e.ret() + + +def sanity_check_general(): + e, t = _test_vars(window := _test_window()) + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.a() # Animation + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Animation") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.c() # Compositing + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Compositing") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.g() # Geometry Nodes + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Geometry Nodes") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.l() # Layout + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Layout") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.m() # Modeling + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Modeling") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.r() # Rendering + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.s() # Scripting + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Scripting") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.p() # Sculpting + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Sculpting") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.h() # Shading + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Shading") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.t() # Texture Paint + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Texture Paint") + + yield from _call_by_name(e, "Add Workspace") + yield e.g() # General + yield e.u() # UV Editing + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "UV Editing") + + +def sanity_check_2d_animation(): + e, t = _test_vars(window := _test_window()) + + yield from _call_by_name(e, "Add Workspace") + yield e.d() # 2D Animation + yield e.d() # 2D Animation + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "2D Animation") + + yield from _call_by_name(e, "Add Workspace") + yield e.d() # 2D Animation + yield e.f() # 2D Full Canvas + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "2D Full Canvas") + + yield from _call_by_name(e, "Add Workspace") + yield e.d() # 2D Animation + yield e.c() # Compositing + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Compositing") + + yield from _call_by_name(e, "Add Workspace") + yield e.d() # 2D Animation + yield e.r() # Rendering + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering") + + +def sanity_check_sculpting(): + e, t = _test_vars(window := _test_window()) + + yield from _call_by_name(e, "Add Workspace") + yield e.s() # Sculpting + yield e.s() # Sculpting + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Sculpting") + + yield from _call_by_name(e, "Add Workspace") + yield e.s() # Sculpting + yield e.h() # Shading + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Shading") + + +def sanity_check_vfx(): + e, t = _test_vars(window := _test_window()) + + yield from _call_by_name(e, "Add Workspace") + yield e.v() # VFX + yield e.c() # Compositing + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Compositing") + + yield from _call_by_name(e, "Add Workspace") + yield e.v() # VFX + yield e.m() # Masking + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Masking") + + yield from _call_by_name(e, "Add Workspace") + yield e.v() # VFX + yield e.t() # Motion Tracking + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Motion Tracking") + + yield from _call_by_name(e, "Add Workspace") + yield e.v() # VFX + yield e.r() # Rendering + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering") + + +def sanity_check_video_editing(): + e, t = _test_vars(window := _test_window()) + + yield from _call_by_name(e, "Add Workspace") + yield e.e() # Video Editing + yield e.r() # Rendering + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Rendering") + + yield from _call_by_name(e, "Add Workspace") + yield e.e() # Video Editing + yield e.v() # Video Editing + t.assertEqual(window.workspace.name_full.split(".", 1)[0], "Video Editing") diff --git a/tests/utils/blender_headless.py b/tests/utils/blender_headless.py index c0a2f672cb4..54a9873da9a 100644 --- a/tests/utils/blender_headless.py +++ b/tests/utils/blender_headless.py @@ -27,6 +27,9 @@ Environment Variables: where the headless session doesn't define a seat. - ``USE_DEBUG``: When nonzero: Run Blender in a debugger. +- ``PASS_THROUGH``: When nonzero: + Don't start a display server to run Blender in. + It's useful to execute Blender from this wrapper script to provide additional control of the environment. WAYLAND Environment Variables: @@ -67,6 +70,9 @@ def environ_nonzero(var: str) -> bool: BLENDER_BIN = os.environ.get("BLENDER_BIN", "blender") +# Skips starting a display server, run Blender in the user's environment. +PASS_THROUGH = environ_nonzero("PASS_THROUGH") + # For debugging, print out all information. VERBOSE = environ_nonzero("VERBOSE") @@ -98,6 +104,33 @@ class backend_base: return 1 +class backend_passthrough(backend_base): + @staticmethod + def run(blender_args: Sequence[str]) -> int: + with tempfile.TemporaryDirectory() as empty_user_dir: + blender_env = {**os.environ, "BLENDER_USER_RESOURCES": empty_user_dir} + + cmd = [ + # "strace", # Can be useful for debugging any startup issues. + BLENDER_BIN, + *blender_args, + ] + + if USE_DEBUG: + cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd] + + if VERBOSE: + print("Env:", blender_env) + print("Run:", cmd) + with subprocess.Popen(cmd, env=blender_env) as proc_blender: + proc_blender.communicate() + blender_exit_code = proc_blender.returncode + del cmd + + # Forward Blender's exit code. + return blender_exit_code + + class backend_wayland(backend_base): @staticmethod def _wait_for_wayland_server(*, socket: str, timeout: float) -> bool: @@ -303,29 +336,29 @@ class backend_wayland(backend_base): # Wait for the interrupt to be handled. proc_server.communicate() return 1 + with tempfile.TemporaryDirectory() as empty_user_dir: + blender_env = {**os.environ, "WAYLAND_DISPLAY": socket, "BLENDER_USER_RESOURCES": empty_user_dir} - blender_env = {**os.environ, "WAYLAND_DISPLAY": socket} + # Needed so Blender can find WAYLAND libraries such as `libwayland-cursor.so`. + if weston_env is not None and "LD_LIBRARY_PATH" in weston_env: + blender_env["LD_LIBRARY_PATH"] = weston_env["LD_LIBRARY_PATH"] - # Needed so Blender can find WAYLAND libraries such as `libwayland-cursor.so`. - if weston_env is not None and "LD_LIBRARY_PATH" in weston_env: - blender_env["LD_LIBRARY_PATH"] = weston_env["LD_LIBRARY_PATH"] + cmd = [ + # "strace", # Can be useful for debugging any startup issues. + BLENDER_BIN, + *blender_args, + ] - cmd = [ - # "strace", # Can be useful for debugging any startup issues. - BLENDER_BIN, - *blender_args, - ] + if USE_DEBUG: + cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd] - if USE_DEBUG: - cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd] - - if VERBOSE: - print("Env:", blender_env) - print("Run:", cmd) - with subprocess.Popen(cmd, env=blender_env) as proc_blender: - proc_blender.communicate() - blender_exit_code = proc_blender.returncode - del cmd + if VERBOSE: + print("Env:", blender_env) + print("Run:", cmd) + with subprocess.Popen(cmd, env=blender_env) as proc_blender: + proc_blender.communicate() + blender_exit_code = proc_blender.returncode + del cmd # Blender has finished, close the server. proc_server.send_signal(signal.SIGINT) @@ -340,13 +373,17 @@ class backend_wayland(backend_base): # Main Function def main() -> int: - match sys.platform: - case "darwin": - backend = backend_base - case "win32": - backend = backend_base - case _: - backend = backend_wayland + backend: type[backend_base] + if PASS_THROUGH: + backend = backend_passthrough + else: + match sys.platform: + case "darwin": + backend = backend_base + case "win32": + backend = backend_base + case _: + backend = backend_wayland return backend.run(sys.argv[1:])