Repository: beeware/briefcase
Pull request #1549 (2023-11-21 18:58:53)

Introduce package for Briefcase Automation
## Changes
- Creates a new package for Briefcase automation in CI
- This package facilitates the effort in https://github.com/beeware/.github/pull/69

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct

rmartin16: For posterity:
```
 [verifyapp] Built build/verifyapp/ios/xcode/build/Debug-iphonesimulator/Verify App.app

[verifyapp] Starting app on an iPhone SE (3rd generation) running iOS 16.2 (device UDID 1A0F79A9-B187-44AF-9000-8558A7179437)
Booting simulator... done
The application /Applications/Xcode_14.2.app/Contents/Developer/Applications/Simulator.app cannot be opened for an unexpected reason, error=Error Domain=NSOSStatusErrorDomain Code=-600 "procNotFound: no eligible process with specified descriptor" UserInfo={_LSLine=387, _LSFunction=_LSAnnotateAndSendAppleEventWithOptions}
Opening simulator...

Unable to open iPhone SE (3rd generation) simulator running 16.2

Log saved to /Users/runner/work/briefcase/briefcase/apps/verifyapp/logs/briefcase.2023_11_21-20_53_13.run.log
```

[briefcase.2023_11_21-20_53_13.run.log](https://github.com/beeware/briefcase/files/13431256/briefcase.2023_11_21-20_53_13.run.log)

rmartin16: I'll pause here for comments on how I've incorporated the briefcase automation plugin in to the Briefcase repo.

This approach of a dedicated top-level directory seemed appropriate given the options I could muster. For instance, we could use the Toga approach or nested pyprojects within `src` but that seems particularly invasive and disruptive just to introduce a small package needed for CI testing. Definitely open to other ideas.

Something I'd also like to explore is how to make an extra dependency group for this automation package; that may help simplify getting it installed in to CI. However, I'm not sure how a reference to this dependency could be reliable in all cases....

Another alternative altogether I've considered is _not_ creating a new package....but to include these automation  bootstraps within Briefcase directly....such that they would even be shipped with releases. However, they would not be exposed as entry points by default. Instead, they could be dynamically loaded at some point during testing.

Patch:
diff --git a/automation/README.md b/automation/README.md
new file mode 100644
index 00000000..5db7cfce
--- /dev/null
+++ b/automation/README.md
@@ -0,0 +1,12 @@
+## Briefcase Automation
+
+This package provides Briefcase plugins to facilitate automation in CI.
+
+This package is internal to Briefcase's own development and is not needed to create,
+develop, or distribute apps created with Briefcase.
+
+### Bootstraps
+
+There are bootstrap plugins for each GUI toolkit; each allows for Briefcase to create
+a project using the toolkit but when the project's app runs, the app automatically
+exits after a few seconds.
diff --git a/automation/pyproject.toml b/automation/pyproject.toml
new file mode 100644
index 00000000..7922c86a
--- /dev/null
+++ b/automation/pyproject.toml
@@ -0,0 +1,28 @@
+[build-system]
+requires = [
+    # keep versions in sync with ../pyproject.toml
+    "setuptools==69.0.0",
+    "setuptools_scm==8.0.4",
+    "setuptools_dynamic_dependencies @ git+https://github.com/beeware/setuptools_dynamic_dependencies",
+]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "x-briefcase-automation"
+description = "A Briefcase plugin for CI automation."
+readme = "README.md"
+license.text = "New BSD"
+classifiers = ["Private :: Do Not Upload"]
+dynamic = ["version", "dependencies"]
+
+[project.entry-points."briefcase.bootstraps"]
+"Toga Automation" = "automation.bootstraps.toga:TogaAutomationBootstrap"
+"PySide6 Automation" = "automation.bootstraps.pyside6:PySide6AutomationBootstrap"
+"Pygame Automation" = "automation.bootstraps.pygame:PygameAutomationBootstrap"
+"PursuedPyBear Automation" = "automation.bootstraps.pursuedpybear:PursuedPyBearAutomationBootstrap"
+
+[tool.setuptools_scm]
+root = "../"
+
+[tool.setuptools_dynamic_dependencies]
+dependencies = ["briefcase == {version}"]
diff --git a/automation/src/automation/__init__.py b/automation/src/automation/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/automation/src/automation/bootstraps/__init__.py b/automation/src/automation/bootstraps/__init__.py
new file mode 100644
index 00000000..9190ec7c
--- /dev/null
+++ b/automation/src/automation/bootstraps/__init__.py
@@ -0,0 +1,2 @@
+BRIEFCASE_EXIT_SUCCESS_SIGNAL = ">>>>>>>>>> EXIT 0 <<<<<<<<<<"
+EXIT_SUCCESS_NOTIFY = ">>> successfully started...exiting <<<"
diff --git a/automation/src/automation/bootstraps/pursuedpybear.py b/automation/src/automation/bootstraps/pursuedpybear.py
new file mode 100644
index 00000000..0545adea
--- /dev/null
+++ b/automation/src/automation/bootstraps/pursuedpybear.py
@@ -0,0 +1,56 @@
+from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY
+from briefcase.bootstraps import PursuedPyBearGuiBootstrap
+
+
+class PursuedPyBearAutomationBootstrap(PursuedPyBearGuiBootstrap):
+    def app_source(self):
+        return f"""\
+import importlib.metadata
+import os
+import sys
+
+import ppb
+
+
+class {{{{ cookiecutter.class_name }}}}(ppb.Scene):
+    def __init__(self, **props):
+        super().__init__(**props)
+        self.updates: int = 0
+
+        self.add(
+            ppb.Sprite(
+                image=ppb.Image("{{{{ cookiecutter.module_name }}}}/resources/{{{{ cookiecutter.app_name }}}}.png"),
+            )
+        )
+
+    def on_update(self, event, signal):
+        self.updates += 1
+        # quit after 2 seconds since on_update is run 60 times/second
+        if self.updates > 120:
+            print("{EXIT_SUCCESS_NOTIFY}")
+            print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}")
+            signal(ppb.events.Quit())
+
+
+def main():
+    # Linux desktop environments use an app's .desktop file to integrate the app
+    # in to their application menus. The .desktop file of this app will include
+    # the StartupWMClass key, set to app's formal name. This helps associate the
+    # app's windows to its menu item.
+    #
+    # For association to work, any windows of the app must have WMCLASS property
+    # set to match the value set in app's desktop file. For PPB, this is set
+    # using the SDL_VIDEO_X11_WMCLASS environment variable.
+
+    # Find the name of the module that was used to start the app
+    app_module = sys.modules["__main__"].__package__
+    # Retrieve the app's metadata
+    metadata = importlib.metadata.metadata(app_module)
+
+    os.environ["SDL_VIDEO_X11_WMCLASS"] = metadata["Formal-Name"]
+
+    ppb.run(
+        starting_scene={{{{ cookiecutter.class_name }}}},
+        title=metadata["Formal-Name"],
+    )
+"""
diff --git a/automation/src/automation/bootstraps/pygame.py b/automation/src/automation/bootstraps/pygame.py
new file mode 100644
index 00000000..0f2fe8c9
--- /dev/null
+++ b/automation/src/automation/bootstraps/pygame.py
@@ -0,0 +1,61 @@
+from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY
+from briefcase.bootstraps import PygameGuiBootstrap
+
+
+class PygameAutomationBootstrap(PygameGuiBootstrap):
+    def app_source(self):
+        return f"""\
+import importlib.metadata
+import os
+import sys
+from pathlib import Path
+
+import pygame
+
+
+SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
+WHITE = (255, 255, 255)
+
+
+def main():
+    # Linux desktop environments use an app's .desktop file to integrate the app
+    # in to their application menus. The .desktop file of this app will include
+    # the StartupWMClass key, set to app's formal name. This helps associate the
+    # app's windows to its menu item.
+    #
+    # For association to work, any windows of the app must have WMCLASS property
+    # set to match the value set in app's desktop file. For PyGame, this is set
+    # using the SDL_VIDEO_X11_WMCLASS environment variable.
+
+    # Find the name of the module that was used to start the app
+    app_module = sys.modules["__main__"].__package__
+    # Retrieve the app's metadata
+    metadata = importlib.metadata.metadata(app_module)
+
+    os.environ["SDL_VIDEO_X11_WMCLASS"] = metadata["Formal-Name"]
+
+    # Set the app's runtime icon
+    pygame.display.set_icon(
+        pygame.image.load(Path(__file__).parent / "resources/{{{{ cookiecutter.app_name }}}}.png")
+    )
+
+    pygame.init()
+    pygame.display.set_caption(metadata["Formal-Name"])
+    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
+
+    pygame.time.set_timer(pygame.QUIT, 2000)
+
+    running = True
+    while running:
+        for event in pygame.event.get():
+            if event.type == pygame.QUIT:
+                print("{EXIT_SUCCESS_NOTIFY}")
+                print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}")
+                running = False
+                break
+
+        screen.fill(WHITE)
+        pygame.display.flip()
+
+    pygame.quit()
+"""
diff --git a/automation/src/automation/bootstraps/pyside6.py b/automation/src/automation/bootstraps/pyside6.py
new file mode 100644
index 00000000..20e1ec11
--- /dev/null
+++ b/automation/src/automation/bootstraps/pyside6.py
@@ -0,0 +1,52 @@
+from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY
+from briefcase.bootstraps import PySide6GuiBootstrap
+
+
+class PySide6AutomationBootstrap(PySide6GuiBootstrap):
+    def app_source(self):
+        return f"""\
+import importlib.metadata
+import sys
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import QTimer
+
+
+class {{{{ cookiecutter.class_name }}}}(QtWidgets.QMainWindow):
+    def __init__(self):
+        super().__init__()
+        self.init_ui()
+
+    def init_ui(self):
+        self.setWindowTitle("{{{{ cookiecutter.app_name }}}}")
+        self.show()
+
+        QTimer.singleShot(2000, self.exit_app)
+
+    def exit_app(self):
+        print("{EXIT_SUCCESS_NOTIFY}")
+        print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}")
+        QtWidgets.QApplication.quit()
+
+
+def main():
+    # Linux desktop environments use an app's .desktop file to integrate the app
+    # in to their application menus. The .desktop file of this app will include
+    # the StartupWMClass key, set to app's formal name. This helps associate the
+    # app's windows to its menu item.
+    #
+    # For association to work, any windows of the app must have WMCLASS property
+    # set to match the value set in app's desktop file. For PySide6, this is set
+    # with setApplicationName().
+
+    # Find the name of the module that was used to start the app
+    app_module = sys.modules["__main__"].__package__
+    # Retrieve the app's metadata
+    metadata = importlib.metadata.metadata(app_module)
+
+    QtWidgets.QApplication.setApplicationName(metadata["Formal-Name"])
+
+    app = QtWidgets.QApplication(sys.argv)
+    main_window = {{{{ cookiecutter.class_name }}}}()
+    sys.exit(app.exec())
+"""
diff --git a/automation/src/automation/bootstraps/toga.py b/automation/src/automation/bootstraps/toga.py
new file mode 100644
index 00000000..af271a23
--- /dev/null
+++ b/automation/src/automation/bootstraps/toga.py
@@ -0,0 +1,41 @@
+from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY
+from briefcase.bootstraps import TogaGuiBootstrap
+
+
+class TogaAutomationBootstrap(TogaGuiBootstrap):
+    def app_source(self):
+        return f'''\
+import asyncio
+
+import toga
+from toga.style import Pack
+from toga.style.pack import COLUMN, ROW
+
+
+class {{{{ cookiecutter.class_name }}}}(toga.App):
+    def startup(self):
+        """Construct and show the Toga application.
+
+        Usually, you would add your application to a main content box.
+        We then create a main window (with a name matching the app), and
+        show the main window.
+        """
+        main_box = toga.Box()
+
+        self.main_window = toga.MainWindow(title=self.formal_name)
+        self.main_window.content = main_box
+        self.main_window.show()
+
+        self.add_background_task(self.exit_soon)
+
+    async def exit_soon(self, app: toga.App, **kwargs):
+        """Background task that closes the app after a few seconds."""
+        await asyncio.sleep(2)
+        print("{EXIT_SUCCESS_NOTIFY}")
+        print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}")
+        self.exit()
+
+
+def main():
+    return {{{{ cookiecutter.class_name }}}}()
+'''
diff --git a/changes/1549.misc.rst b/changes/1549.misc.rst
new file mode 100644
index 00000000..4ff04c3b
--- /dev/null
+++ b/changes/1549.misc.rst
@@ -0,0 +1,1 @@
+The Briefcase Automation package was created to facilitate automated testing in CI; for example, starting apps built in CI that can automatically exit.
diff --git a/changes/1560.misc.rst b/changes/1560.misc.rst
deleted file mode 100644
index 2c530498..00000000
--- a/changes/1560.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated tox from 4.11.3 to 4.11.4.
diff --git a/pyproject.toml b/pyproject.toml
index 5bb8e4c3..38d4a6db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,6 @@
 [build-system]
 requires = [
+    # keep versions in sync with automation/pyproject.toml
     "setuptools==69.0.0",
     "setuptools_scm==8.0.4",
 ]
@@ -98,7 +99,7 @@ dev = [
     "pytest == 7.4.3",
     "pytest-xdist == 3.5.0",
     "setuptools_scm == 8.0.4",
-    "tox == 4.11.4",
+    "tox == 4.11.3",
 ]
 docs = [
     "furo == 2023.9.10",
diff --git a/src/briefcase/bootstraps/pursuedpybear.py b/src/briefcase/bootstraps/pursuedpybear.py
index a166fd56..15f682d1 100644
--- a/src/briefcase/bootstraps/pursuedpybear.py
+++ b/src/briefcase/bootstraps/pursuedpybear.py
@@ -52,6 +52,7 @@ def main():
 
 requires = [
     "ppb~=1.1",
+    "pysdl2-dll==2.0.22",
 ]
 test_requires = [
 {%- if cookiecutter.test_framework == "pytest" %}
diff --git a/tox.ini b/tox.ini
index 6e9a284d..a32bee41 100644
--- a/tox.ini
+++ b/tox.ini
@@ -127,12 +127,13 @@ commands =
     lint : python -m sphinx {[docs]sphinx_args_extra} -b linkcheck . {[docs]build_dir}/links
     all  : python -m sphinx {[docs]sphinx_args_extra} -b html . {[docs]build_dir}/html
 
-[testenv:package]
+[testenv:package{,-with-automation}]
 skip_install = True
 passenv = FORCE_COLOR
 deps =
     build==1.0.3
     twine==4.0.2
 commands =
-    python -m build --outdir dist/ .
+    python -m build . --outdir dist/
+    with-automation: python -m build automation/ --outdir dist/
     python -m twine check dist/*


Test patch:
diff --git a/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py
index 8d57aaf9..05f44a47 100644
--- a/tests/commands/new/test_build_context.py
+++ b/tests/commands/new/test_build_context.py
@@ -523,6 +523,7 @@ def main():
 
 requires = [
     "ppb~=1.1",
+    "pysdl2-dll==2.0.22",
 ]
 test_requires = [
 {%- if cookiecutter.test_framework == "pytest" %}