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
This commit is contained in:
Sean Kim
2025-06-12 20:27:38 +02:00
committed by Sean Kim
parent 37f8616bd5
commit f77b1e871d
4 changed files with 277 additions and 66 deletions

View File

@@ -73,8 +73,8 @@ function(add_blender_test_io testname)
endfunction() endfunction()
if(WITH_UI_TESTS) if(WITH_UI_TESTS)
set(_blender_headless_env_vars "BLENDER_BIN=${TEST_BLENDER_EXE}")
if(WITH_UI_TESTS_HEADLESS) 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. # Currently only WAYLAND is supported, support for others may be added later.
# In this case none of the WESTON environment variables will be used. # In this case none of the WESTON environment variables will be used.
@@ -104,43 +104,29 @@ if(WITH_UI_TESTS)
) )
endif() endif()
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() else()
function(add_blender_test_ui testname) list(APPEND _blender_headless_env_vars
# Remove `--background` "PASS_THROUGH=1"
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()
endif() 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() endif()
# Run Python script outside Blender. # Run Python script outside Blender.
@@ -1298,7 +1284,12 @@ if(WITH_UI_TESTS)
# This could be generated with: # This could be generated with:
# `"${TEST_PYTHON_EXE}" "${CMAKE_CURRENT_LIST_DIR}/ui_simulate/run.py" --list-tests` # `"${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. # 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_edit_mode_mix
test_undo.text_editor_simple test_undo.text_editor_simple
test_undo.view3d_edit_mode_multi_window 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_complex
test_undo.view3d_texture_paint_simple test_undo.view3d_texture_paint_simple
) )
foreach(ui_test ${_undo_tests}) foreach(ui_test ${_ui_tests})
add_blender_test_ui( add_blender_test_ui(
"ui_${ui_test}" "ui_${ui_test}"
--enable-event-simulate --enable-event-simulate
@@ -1325,7 +1316,7 @@ if(WITH_UI_TESTS)
--tests "${ui_test}" --tests "${ui_test}"
) )
endforeach() endforeach()
unset(_undo_tests) unset(_ui_tests)
endif() endif()

View File

@@ -23,6 +23,7 @@ For an editor to follow the tests:
import os import os
import sys import sys
import tempfile
def create_parser(): def create_parser():
@@ -137,7 +138,7 @@ def _process_test_id_fn(env, args, test_id):
return test_id, callproc.returncode == 0 return test_id, callproc.returncode == 0
def main(): def run(empty_user_dir):
directory = os.path.dirname(__file__) directory = os.path.dirname(__file__)
if "--list-tests" in sys.argv: if "--list-tests" in sys.argv:
list_tests(directory) list_tests(directory)
@@ -164,6 +165,7 @@ def main():
env = os.environ.copy() env = os.environ.copy()
env.update({ env.update({
"LSAN_OPTIONS": "exitcode=0", "LSAN_OPTIONS": "exitcode=0",
"BLENDER_USER_RESOURCES": empty_user_dir,
}) })
# We could support multiple tests per Blender session. # We could support multiple tests per Blender session.
@@ -188,6 +190,13 @@ def main():
for test_id, ok in results: for test_id, ok in results:
print("OK: " if ok else "FAIL:", test_id) 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__": if __name__ == "__main__":
main() main()

View File

@@ -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")

View File

@@ -27,6 +27,9 @@ Environment Variables:
where the headless session doesn't define a seat. where the headless session doesn't define a seat.
- ``USE_DEBUG``: When nonzero: - ``USE_DEBUG``: When nonzero:
Run Blender in a debugger. 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: WAYLAND Environment Variables:
@@ -67,6 +70,9 @@ def environ_nonzero(var: str) -> bool:
BLENDER_BIN = os.environ.get("BLENDER_BIN", "blender") 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. # For debugging, print out all information.
VERBOSE = environ_nonzero("VERBOSE") VERBOSE = environ_nonzero("VERBOSE")
@@ -98,6 +104,33 @@ class backend_base:
return 1 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): class backend_wayland(backend_base):
@staticmethod @staticmethod
def _wait_for_wayland_server(*, socket: str, timeout: float) -> bool: 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. # Wait for the interrupt to be handled.
proc_server.communicate() proc_server.communicate()
return 1 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`. cmd = [
if weston_env is not None and "LD_LIBRARY_PATH" in weston_env: # "strace", # Can be useful for debugging any startup issues.
blender_env["LD_LIBRARY_PATH"] = weston_env["LD_LIBRARY_PATH"] BLENDER_BIN,
*blender_args,
]
cmd = [ if USE_DEBUG:
# "strace", # Can be useful for debugging any startup issues. cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd]
BLENDER_BIN,
*blender_args,
]
if USE_DEBUG: if VERBOSE:
cmd = ["gdb", BLENDER_BIN, "--ex=run", "--args", *cmd] print("Env:", blender_env)
print("Run:", cmd)
if VERBOSE: with subprocess.Popen(cmd, env=blender_env) as proc_blender:
print("Env:", blender_env) proc_blender.communicate()
print("Run:", cmd) blender_exit_code = proc_blender.returncode
with subprocess.Popen(cmd, env=blender_env) as proc_blender: del cmd
proc_blender.communicate()
blender_exit_code = proc_blender.returncode
del cmd
# Blender has finished, close the server. # Blender has finished, close the server.
proc_server.send_signal(signal.SIGINT) proc_server.send_signal(signal.SIGINT)
@@ -340,13 +373,17 @@ class backend_wayland(backend_base):
# Main Function # Main Function
def main() -> int: def main() -> int:
match sys.platform: backend: type[backend_base]
case "darwin": if PASS_THROUGH:
backend = backend_base backend = backend_passthrough
case "win32": else:
backend = backend_base match sys.platform:
case _: case "darwin":
backend = backend_wayland backend = backend_base
case "win32":
backend = backend_base
case _:
backend = backend_wayland
return backend.run(sys.argv[1:]) return backend.run(sys.argv[1:])