diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index b73c5d82650..87880e74cd9 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:])