diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d871bceb62..0354ce380f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,7 +188,7 @@ jobs: strategy: fail-fast: false matrix: - backend: [ "macOS-x86_64", "macOS-arm64", "windows", "linux", "android", "iOS" ] + backend: [ "macOS-x86_64", "macOS-arm64", "windows", "linux-x11", "linux-wayland","android", "iOS" ] include: - pre-command: "" briefcase-run-prefix: "" @@ -208,7 +208,7 @@ jobs: # We use a fixed Ubuntu version rather than `-latest` because at some point, # `-latest` will be updated, but it will be a soft changeover, which would cause # the system Python version to become inconsistent from run to run. - - backend: "linux" + - backend: "linux-x11" platform: "linux" runs-on: "ubuntu-22.04" # The package list should be the same as in tutorial-0.rst, and the BeeWare @@ -223,12 +223,12 @@ jobs: sudo apt install -y --no-install-recommends \ blackbox pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-webkit2-4.1 - # Start Virtual X server + # Start Virtual X Server echo "Start X server..." Xvfb :99 -screen 0 2048x1536x24 & sleep 1 - # Start Window manager + # Start Window Mmanager echo "Start window manager..." DISPLAY=:99 blackbox & sleep 1 @@ -236,6 +236,32 @@ jobs: setup-python: false # Use the system Python packages app-user-data-path: "$HOME/.local/share/testbed" + - backend: "linux-wayland" + platform: "linux" + runs-on: "ubuntu-22.04" + # The package list should be the same as in tutorial-0.rst, and the BeeWare + # tutorial, plus mutter to provide a window manager, and libjpeg-dev for Pillow. + pre-command: | + sudo apt update -y + sudo apt install -y --no-install-recommends \ + mutter pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-webkit2-4.1 + + # Start Virtual X Server + echo "Start X server..." + Xvfb :99 -screen 0 2048x1536x24 & + sleep 1 + + # Start Window Manager + echo "Start window manager..." + # mutter is being run inside a virtual X server because mutter's headless + # mode is not compatible with Gtk + DISPLAY=:99 MUTTER_DEBUG_DUMMY_MODE_SPECS=2048x1536 \ + mutter --nested --wayland --no-x11 --wayland-display toga & + sleep 1 + briefcase-run-prefix: "WAYLAND_DISPLAY=toga" + setup-python: false # Use the system Python packages + app-user-data-path: "$HOME/.local/share/testbed" + - backend: "windows" platform: "windows" runs-on: "windows-latest" @@ -258,7 +284,7 @@ jobs: platform: "android" runs-on: "ubuntu-latest" briefcase-run-prefix: JAVA_HOME=${JAVA_HOME_17_X64} - briefcase-run-args: > + briefcase-run-args: >- --device '{"avd":"beePhone","skin":"pixel_3a"}' --Xemulator=-no-window --Xemulator=-no-snapshot @@ -304,7 +330,7 @@ jobs: timeout-minutes: 15 run: | ${{ matrix.briefcase-run-prefix }} \ - briefcase run ${{ matrix.platform }} --log --test ${{ matrix.briefcase-run-args }} + briefcase run ${{ matrix.platform }} --log --test ${{ matrix.briefcase-run-args }} -- --ci - name: Upload Logs uses: actions/upload-artifact@v4.3.3 diff --git a/android/tests_backend/window.py b/android/tests_backend/window.py index 8b0d05c408..84dc654c0e 100644 --- a/android/tests_backend/window.py +++ b/android/tests_backend/window.py @@ -8,6 +8,7 @@ class WindowProbe(BaseProbe): def __init__(self, app, window): super().__init__(app) self.native = self.app._impl.native + self.window = window async def wait_for_window(self, message, minimize=False, full_screen=False): await self.redraw(message) diff --git a/changes/2668.misc.rst b/changes/2668.misc.rst new file mode 100644 index 0000000000..d974f313bc --- /dev/null +++ b/changes/2668.misc.rst @@ -0,0 +1 @@ +Add Wayland to the matrix for testbed CI testing. diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 6fad33f81d..2719a5d1ba 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -21,6 +21,7 @@ class AppProbe(BaseProbe): supports_key = True supports_key_mod3 = True + supports_current_window_assignment = True def __init__(self, app): super().__init__() diff --git a/cocoa/tests_backend/window.py b/cocoa/tests_backend/window.py index 7c1e41c50c..4dbcea9fd4 100644 --- a/cocoa/tests_backend/window.py +++ b/cocoa/tests_backend/window.py @@ -24,6 +24,8 @@ class WindowProbe(BaseProbe): supports_move_while_hidden = True supports_multiple_select_folder = True supports_unminimize = True + supports_minimize = True + supports_placement = True def __init__(self, app, window): super().__init__() diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 11aad1ca9c..96a0934993 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -192,13 +192,13 @@ def set_main_window(self, window): def get_screens(self): display = Gdk.Display.get_default() - if "WAYLAND_DISPLAY" in os.environ: # pragma: no cover + if "WAYLAND_DISPLAY" in os.environ: # pragma: no-cover-if-linux-x # `get_primary_monitor()` doesn't work on wayland, so return as it is. return [ ScreenImpl(native=display.get_monitor(i)) for i in range(display.get_n_monitors()) ] - else: + else: # pragma: no-cover-if-linux-wayland primary_screen = ScreenImpl(display.get_primary_monitor()) screen_list = [primary_screen] + [ ScreenImpl(native=display.get_monitor(i)) @@ -252,7 +252,7 @@ def show_cursor(self): # Window control ###################################################################### - def get_current_window(self): + def get_current_window(self): # pragma: no-cover-if-linux-wayland current_window = self.native.get_active_window()._impl return current_window if current_window.interface.visible else None diff --git a/gtk/src/toga_gtk/screens.py b/gtk/src/toga_gtk/screens.py index f16ea792eb..1ef6f7a98a 100644 --- a/gtk/src/toga_gtk/screens.py +++ b/gtk/src/toga_gtk/screens.py @@ -31,10 +31,10 @@ def get_size(self) -> Size: return Size(geometry.width, geometry.height) def get_image_data(self): - if "WAYLAND_DISPLAY" in os.environ: + if "WAYLAND_DISPLAY" in os.environ: # pragma: no cover # Not implemented on wayland due to wayland security policies. self.interface.factory.not_implemented("Screen.get_image_data() on Wayland") - else: + else: # pragma: no-cover-if-linux-wayland # Only works for Xorg display = self.native.get_display() screen = display.get_default_screen() diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 37b3da5a7b..f18003857c 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -13,6 +13,10 @@ class AppProbe(BaseProbe): supports_key = True supports_key_mod3 = True + # Gtk 3.24.41 ships with Ubuntu 24.04 where present() works on Wayland + supports_current_window_assignment = not ( + BaseProbe.IS_WAYLAND and BaseProbe.GTK_VERSION < (3, 24, 41) + ) def __init__(self, app): super().__init__() diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py index 8c026917e7..9f510ead54 100644 --- a/gtk/tests_backend/probe.py +++ b/gtk/tests_backend/probe.py @@ -1,10 +1,14 @@ import asyncio +import os import toga from toga_gtk.libs import Gtk class BaseProbe: + GTK_VERSION = Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION, Gtk.MICRO_VERSION + IS_WAYLAND = os.environ.get("WAYLAND_DISPLAY", "") != "" + def repaint_needed(self): return Gtk.events_pending() diff --git a/gtk/tests_backend/screens.py b/gtk/tests_backend/screens.py index 399544797c..a31e6b0518 100644 --- a/gtk/tests_backend/screens.py +++ b/gtk/tests_backend/screens.py @@ -1,5 +1,3 @@ -import os - import pytest from gi.repository import GdkX11 @@ -14,7 +12,7 @@ def __init__(self, screen): self.screen = screen self._impl = screen._impl self.native = screen._impl.native - if "WAYLAND_DISPLAY" in os.environ: + if self.IS_WAYLAND: # The native display type on Wayland is `__gi__.GdkWaylandMonitor` # However, that class can't be imported directly. pass @@ -22,7 +20,7 @@ def __init__(self, screen): assert isinstance(self.native, GdkX11.X11Monitor) def get_screenshot(self, format=TogaImage): - if "WAYLAND_DISPLAY" in os.environ: - pytest.skip("Screen.as_image() is not implemented on wayland.") + if self.IS_WAYLAND: + pytest.skip("Screen.as_image() is not implemented on Wayland.") else: return self.screen.as_image(format=format) diff --git a/gtk/tests_backend/widgets/canvas.py b/gtk/tests_backend/widgets/canvas.py index 81deb6caa9..1d00c6649a 100644 --- a/gtk/tests_backend/widgets/canvas.py +++ b/gtk/tests_backend/widgets/canvas.py @@ -11,7 +11,12 @@ class CanvasProbe(SimpleProbe): native_class = Gtk.DrawingArea def reference_variant(self, reference): - if reference in {"write_text", "multiline_text"}: + if reference == "multiline_text": + if self.IS_WAYLAND: + return f"{reference}-gtk-wayland" + else: + return f"{reference}-gtk-x11" + elif reference == "write_text": return f"{reference}-gtk" else: return reference diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index 28b1ffaf42..0abda6b85e 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -15,6 +15,9 @@ class WindowProbe(BaseProbe): supports_multiple_select_folder = True supports_move_while_hidden = False supports_unminimize = False + # Wayland mostly prohibits interaction with the larger windowing environment + supports_minimize = not BaseProbe.IS_WAYLAND + supports_placement = not BaseProbe.IS_WAYLAND def __init__(self, app, window): super().__init__() diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index b0a3ce4c07..d3a4a7faaf 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -5,6 +5,7 @@ version = "0.0.1" [project.optional-dependencies] test = [ "coverage==7.5.3", + "coverage-conditional-plugin == 0.9.0", # fonttools is only needed by Android, but we need to use # sys.platform == 'linux' as there's no dependency identifier # that can target Android exclusively until 3.13 lands. diff --git a/testbed/src/testbed/resources/canvas/multiline_text-gtk-wayland.png b/testbed/src/testbed/resources/canvas/multiline_text-gtk-wayland.png new file mode 100644 index 0000000000..ad2503ac6c Binary files /dev/null and b/testbed/src/testbed/resources/canvas/multiline_text-gtk-wayland.png differ diff --git a/testbed/src/testbed/resources/canvas/multiline_text-gtk.png b/testbed/src/testbed/resources/canvas/multiline_text-gtk-x11.png similarity index 100% rename from testbed/src/testbed/resources/canvas/multiline_text-gtk.png rename to testbed/src/testbed/resources/canvas/multiline_text-gtk-x11.png diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 6c8e85bcd1..3b897a9190 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -37,7 +37,7 @@ async def test_full_screen(app): app.set_full_screen(app.current_window) app.exit_full_screen() - async def test_current_window(app, main_window, main_window_probe): + async def test_current_window(app, main_window, app_probe, main_window_probe): """The current window can be retrieved""" assert app.current_window == main_window @@ -153,12 +153,13 @@ async def test_menu_exit(monkeypatch, app, app_probe, mock_app_exit): mock_app_exit.assert_called_once_with() async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) window1.show() @@ -209,8 +210,9 @@ async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): window3.close() async def test_menu_minimize(app, app_probe): + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) window1.show() @@ -228,10 +230,11 @@ async def test_menu_minimize(app, app_probe): async def test_full_screen(app, app_probe): """Window can be made full screen""" + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) window1_probe = window_probe(app, window1) window2_probe = window_probe(app, window2) @@ -351,7 +354,8 @@ async def test_show_hide_cursor(app, app_probe): async def test_current_window(app, app_probe, main_window): """The current window can be retrieved.""" try: - assert app.current_window == main_window + if app_probe.supports_current_window_assignment: + assert app.current_window == main_window # When all windows are hidden, WinForms and Cocoa return None, while GTK # returns the last active window. @@ -363,12 +367,13 @@ async def test_current_window(app, app_probe, main_window): finally: main_window.show() + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + try: - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) # We don't need to probe anything window specific; we just need @@ -383,11 +388,13 @@ async def test_current_window(app, app_probe, main_window): app.current_window = window2 await window1_probe.wait_for_window("Window 2 is current") - assert app.current_window == window2 + if app_probe.supports_current_window_assignment: + assert app.current_window == window2 app.current_window = window3 await window1_probe.wait_for_window("Window 3 is current") - assert app.current_window == window3 + if app_probe.supports_current_window_assignment: + assert app.current_window == window3 # app_probe.platform tests? finally: @@ -511,8 +518,8 @@ async def test_menu_visit_homepage(monkeypatch, app, app_probe): """The visit homepage menu item can be used""" # If the backend defines a VISIT_HOMEPAGE command, mock the visit_homepage method, # and rebind the visit homepage command to the visit_homepage method. + visit_homepage = Mock() if toga.Command.VISIT_HOMEPAGE in app.commands: - visit_homepage = Mock() monkeypatch.setattr(app, "visit_homepage", visit_homepage) monkeypatch.setattr( app.commands[toga.Command.VISIT_HOMEPAGE], "_action", app.visit_homepage @@ -593,7 +600,7 @@ async def test_menu_items(app, app_probe): enabled=False, ) - # Dislble the items + # Disable the items app.disabled_cmd.enabled = False app.no_action_cmd.enabled = False diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 01dcf24363..73e2067fb9 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -13,7 +13,7 @@ from testbed.app import main -def run_tests(app, cov, args, report_coverage, run_slow): +def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): try: # Wait for the app's main window to be visible. print("Waiting for app to be ready for testing... ", end="", flush=True) @@ -26,6 +26,8 @@ def run_tests(app, cov, args, report_coverage, run_slow): project_path = Path(__file__).parent.parent os.chdir(project_path) + os.environ["RUNNING_IN_CI"] = "true" if running_in_ci else "" + app.returncode = pytest.main( [ # Output formatting @@ -115,6 +117,14 @@ def run_tests(app, cov, args, report_coverage, run_slow): branch=True, source_pkgs=[toga_backend], ) + cov.set_option("run:plugins", ["coverage_conditional_plugin"]) + cov.set_option( + "coverage_conditional_plugin:rules", + { + "no-cover-if-linux-wayland": "os_environ.get('WAYLAND_DISPLAY', '') != ''", + "no-cover-if-linux-x": "os_environ.get('WAYLAND_DISPLAY', 'not-set') == 'not-set'", + }, + ) cov.start() # Create the test app, starting the test suite as a background task @@ -138,6 +148,12 @@ def run_tests(app, cov, args, report_coverage, run_slow): except ValueError: report_coverage = False + try: + args.remove("--ci") + running_in_ci = True + except ValueError: + running_in_ci = False + # If there are no other specified arguments, default to running the whole suite, # and reporting coverage. if len(args) == 0: @@ -152,6 +168,7 @@ def run_tests(app, cov, args, report_coverage, run_slow): args=args, run_slow=run_slow, report_coverage=report_coverage, + running_in_ci=running_in_ci, ) ) # Queue a background task to run that will start the main thread. We do this, diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index e351ed1364..a5c4e8c4b9 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -1,4 +1,5 @@ import math +import os from math import pi, radians from unittest.mock import Mock, call @@ -693,6 +694,10 @@ async def test_write_text(canvas, probe): assert_reference(probe, "write_text", threshold=0.07) +@pytest.mark.xfail( + condition=os.environ.get("RUNNING_IN_CI") != "true", + reason="may fail outside of a GitHub runner environment", +) async def test_multiline_text(canvas, probe): "Multiline text can be measured and written" diff --git a/testbed/tests/window/test_window.py b/testbed/tests/window/test_window.py index 70d20016e7..db9e9396a8 100644 --- a/testbed/tests/window/test_window.py +++ b/testbed/tests/window/test_window.py @@ -47,7 +47,7 @@ async def test_title(main_window, main_window_probe): await main_window_probe.wait_for_window("Window title can be reverted") -# Mobile platforms have different windowing characterics, so they have different tests. +# Mobile platforms have different windowing characteristics, so they have different tests. if toga.platform.current_platform in {"iOS", "android"}: #################################################################################### # Mobile platform tests @@ -92,9 +92,9 @@ async def test_move_and_resize(main_window, main_window_probe, capsys): assert main_window.size == initial_size assert main_window.position == (0, 0) - try: - orig_content = main_window.content + orig_content = main_window.content + try: box1 = toga.Box( style=Pack(background_color=REBECCAPURPLE, width=10, height=10) ) @@ -203,7 +203,8 @@ async def test_secondary_window_with_args(app, second_window, second_window_prob assert second_window.title == "Secondary Window" assert second_window.size == (300, 200) - assert second_window.position == (200, 300) + if second_window_probe.supports_placement: + assert second_window.position == (200, 300) second_window_probe.close() await second_window_probe.wait_for_window( @@ -231,10 +232,10 @@ async def test_secondary_window_with_content(app): label1 = toga.Label("Hello World") content = toga.Box(children=[label1]) - try: - window_with_content = toga.Window(content=content) - window_with_content_probe = window_probe(app, window_with_content) + window_with_content = toga.Window(content=content) + window_with_content_probe = window_probe(app, window_with_content) + try: window_with_content.show() await window_with_content_probe.wait_for_window( "Create a window with initial content" @@ -349,14 +350,16 @@ async def test_visibility(app, second_window, second_window_probe): assert second_window.visible assert second_window.size == (640, 480) - assert second_window.position == (200, 150) + if second_window_probe.supports_placement: + assert second_window.position == (200, 150) # Move the window second_window.position = (250, 200) await second_window_probe.wait_for_window("Secondary window has been moved") assert second_window.size == (640, 480) - assert second_window.position == (250, 200) + if second_window_probe.supports_placement: + assert second_window.position == (250, 200) # Resize the window second_window.size = (300, 250) @@ -386,7 +389,10 @@ async def test_visibility(app, second_window, second_window_probe): assert second_window.visible assert second_window.size == (250, 200) - if second_window_probe.supports_move_while_hidden: + if ( + second_window_probe.supports_move_while_hidden + and second_window_probe.supports_placement + ): assert second_window.position == (300, 150) second_window_probe.minimize() @@ -396,7 +402,8 @@ async def test_visibility(app, second_window, second_window_probe): minimize=True, ) - assert second_window_probe.is_minimized + if second_window_probe.supports_minimize: + assert second_window_probe.is_minimized if second_window_probe.supports_unminimize: second_window_probe.unminimize() @@ -426,7 +433,8 @@ async def test_move_and_resize(second_window, second_window_probe): second_window.position = (150, 50) await second_window_probe.wait_for_window("Secondary window has been moved") - assert second_window.position == (150, 50) + if second_window_probe.supports_placement: + assert second_window.position == (150, 50) second_window.size = (200, 150) await second_window_probe.wait_for_window("Secondary window has been resized") @@ -521,11 +529,13 @@ async def test_screen(second_window, second_window_probe): # Move the window using absolute position. second_window.position = (200, 200) await second_window_probe.wait_for_window("Secondary window has been moved") - assert second_window.position != initial_position + if second_window_probe.supports_placement: + assert second_window.position != initial_position # `position` and `screen_position` will be same as the window will be in primary screen. - assert second_window.position == (200, 200) - assert second_window.screen_position == (200, 200) + if second_window_probe.supports_placement: + assert second_window.position == (200, 200) + assert second_window.screen_position == (200, 200) # Move the window between available screens and assert its `screen_position` for screen in second_window.app.screens: diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 84d2cd2f79..932a9bf534 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -18,6 +18,7 @@ class AppProbe(BaseProbe): supports_key = True supports_key_mod3 = False + supports_current_window_assignment = True def __init__(self, app): super().__init__() diff --git a/winforms/tests_backend/window.py b/winforms/tests_backend/window.py index 55d6c4d63c..8c32c51b4d 100644 --- a/winforms/tests_backend/window.py +++ b/winforms/tests_backend/window.py @@ -23,6 +23,8 @@ class WindowProbe(BaseProbe): supports_move_while_hidden = True supports_multiple_select_folder = False supports_unminimize = True + supports_minimize = True + supports_placement = True def __init__(self, app, window): super().__init__()