diff --git a/changes/1166.feature.rst b/changes/1166.feature.rst new file mode 100644 index 000000000..d9eb8330f --- /dev/null +++ b/changes/1166.feature.rst @@ -0,0 +1 @@ +Support for running project tests was added to the static web backend diff --git a/docs/reference/platforms/web/static.rst b/docs/reference/platforms/web/static.rst index bb455bbfc..9ef540967 100644 --- a/docs/reference/platforms/web/static.rst +++ b/docs/reference/platforms/web/static.rst @@ -46,7 +46,8 @@ Web projects use a single 32px ``.png`` format icon as the site icon. Splash Image format =================== -Web projects do not support splash screens or installer images. +Web projects use a single ``.png`` image as the splash screen. The image can be +any size; a size of approximately 250x200 px is recommended. Application configuration ========================= diff --git a/setup.cfg b/setup.cfg index 810541366..f1b6dc67b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -78,6 +78,8 @@ install_requires = dmgbuild >= 1.6, < 2.0; sys_platform == "darwin" GitPython >= 3.1, < 4.0 platformdirs >= 2.6, < 4.0 + # 2023-05-09: Playwright isn't supported on Python 3.12+ + playwright >= 1.33.0, < 2.0; python_version < "3.12" psutil >= 5.9, < 6.0 requests >= 2.28, < 3.0 rich >= 12.6, < 14.0 diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 6a54b499d..07284db10 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -15,14 +15,12 @@ class LogFilter: def __init__( self, - log_popen, clean_filter, clean_output, exit_filter, ): """Create a filter for a log stream. - :param log_popen: The Popen object for the stream producing the logs. :param clean_filter: A function that will filter a line of logs, returning a "clean" line without any log system preamble. :param clean_output: Should the output displayed to the user be the "clean" @@ -32,7 +30,6 @@ def __init__( exit status of the process if an exit condition has been detected, or None if the log stream should continue. """ - self.log_popen = log_popen self.returncode = None self.clean_filter = clean_filter self.clean_output = clean_output @@ -150,7 +147,6 @@ def _stream_app_logs( ) log_filter = LogFilter( - popen, clean_filter=clean_filter, clean_output=clean_output, exit_filter=exit_filter, diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index b4e18b920..2192eefd3 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -1,14 +1,14 @@ import errno +import re import subprocess import sys import webbrowser from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from threading import Thread from typing import Any, List from zipfile import ZipFile -from briefcase.console import Log - try: import tomllib except ModuleNotFoundError: # pragma: no-cover-if-gte-py310 @@ -16,6 +16,12 @@ import tomli_w +try: + from playwright.sync_api import sync_playwright +except ImportError: # pragma: no-cover-if-lt-py312 + # TODO: Playwright doesn't support Python 3.12 yet. + sync_playwright = None + from briefcase.commands import ( BuildCommand, CreateCommand, @@ -25,22 +31,36 @@ RunCommand, UpdateCommand, ) +from briefcase.commands.create import _is_local_requirement +from briefcase.commands.run import LogFilter from briefcase.config import AppConfig -from briefcase.exceptions import BriefcaseCommandError, BriefcaseConfigError +from briefcase.console import Log +from briefcase.exceptions import ( + BriefcaseCommandError, + BriefcaseConfigError, + BriefcaseTestSuiteFailure, +) +from briefcase.integrations.subprocess import StopStreaming class StaticWebMixin: output_format = "static" platform = "web" + def local_requirements_path(self, app): + return self.bundle_path(app) / "_requirements" + def project_path(self, app): return self.bundle_path(app) / "www" def binary_path(self, app): return self.bundle_path(app) / "www" / "index.html" + def static_path(self, app): + return self.project_path(app) / "static" + def wheel_path(self, app): - return self.project_path(app) / "static" / "wheels" + return self.static_path(app) / "wheels" def distribution_path(self, app): return self.dist_path / f"{app.formal_name}-{app.version}.web.zip" @@ -55,6 +75,53 @@ def output_format_template_context(self, app: AppConfig): "style_framework": getattr(app, "style_framework", "None"), } + def _write_requirements_file( + self, + app: AppConfig, + requires: list[str], + requirements_path: Path, + ): + if self.local_requirements_path(app).exists(): + with self.input.wait_bar("Removing old local wheels..."): + self.tools.shutil.rmtree(self.local_requirements_path(app)) + + self.local_requirements_path(app).mkdir(parents=True) + + with self.input.wait_bar("Writing requirements file..."): + with requirements_path.open("w", encoding="utf-8") as f: + if requires: + for requirement in requires: + if _is_local_requirement(requirement): + # If the requirement is a local path, build a wheel for the requirement, + # and update the requirements file to reference that wheel. + try: + self.tools.subprocess.run( + [ + sys.executable, + "-u", + "-m", + "pip", + "wheel", + "--no-deps", + "-w", + self.local_requirements_path(app), + requirement, + ], + check=True, + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError( + f"Unable to install requirements for app {app.app_name!r}" + ) from e + + else: + # Otherwise, just use the requirement as defined. + f.write(f"{requirement}\n") + + # Append all the local wheels to the requirements file. + for filename in self.local_requirements_path(app).glob("*.whl"): + f.write(f"{filename.relative_to(self.bundle_path(app))}\n") + class StaticWebUpdateCommand(StaticWebCreateCommand, UpdateCommand): description = "Update an existing static web project." @@ -67,61 +134,230 @@ class StaticWebOpenCommand(StaticWebMixin, OpenCommand): class StaticWebBuildCommand(StaticWebMixin, BuildCommand): description = "Build a static web project." - def _trim_file(self, path, sentinel): - """Re-write a file to strip any content after a sentinel line. - - The file is stored in-memory, so it shouldn't be used on files with a *lot* of - content before the sentinel. - - :param path: The path to the file to be trimmed - :param sentinel: The content of the sentinel line. This will become the last - line in the trimmed file. - """ - content = [] - with path.open("r", encoding="utf-8") as f: - for line in f: - if line.rstrip("\n") == sentinel: - content.append(line) - break - else: - content.append(line) - - with path.open("w", encoding="utf-8") as f: - for line in content: - f.write(line) - - def _process_wheel(self, wheelfile, css_file): + def _process_wheel(self, wheelfile, inserts, static_path): """Process a wheel, extracting any content that needs to be compiled into the final project. + Extracted content comes in two forms: + * inserts - pieces of content that will be inserted into existing files + * static - content that will be copied wholesale. Any content in a ``static`` + folder inside the wheel will be copied as-is to the static folder, + namespaced by the package name of the wheel. + + Any pre-existing static content for the wheel will be deleted. + :param wheelfile: The path to the wheel file to be processed. - :param css_file: A file handle, opened for write/append, to which any extracted - CSS content will be appended. + :param inserts: The inserts collection for the app + :param static_path: The location where static content should be unpacked """ - package = " ".join(wheelfile.name.split("-")[:2]) + parts = wheelfile.name.split("-") + package_name = parts[0] + package_version = parts[1] + package_key = f"{package_name} {package_version}" + + # Purge any existing extracted static files + if (static_path / package_name).exists(): + self.tools.shutil.rmtree(static_path / package_name) + with ZipFile(wheelfile) as wheel: for filename in wheel.namelist(): path = Path(filename) - # Any CSS file in a `static` folder is appended - if ( - len(path.parts) > 1 - and path.parts[1] == "static" - and path.suffix == ".css" - ): - self.logger.info(f" Found {filename}") - css_file.write( - "\n/*******************************************************\n" - ) - css_file.write(f" * {package}::{'/'.join(path.parts[2:])}\n") - css_file.write( - " *******************************************************/\n\n" - ) - css_file.write(wheel.read(filename).decode("utf-8")) + if len(path.parts) > 1: + if path.parts[1] == "inserts": + source = str(Path(*path.parts[2:])) + content = wheel.read(filename).decode("utf-8") + if ":" in path.name: + target, insert = source.split(":") + self.logger.info( + f" {source}: Adding {insert} insert for {target}" + ) + else: + target = path.suffix[1:].upper() + insert = source + self.logger.info(f" {source}: Adding {target} insert") + + inserts.setdefault(target, {}).setdefault(insert, {})[ + package_key + ] = content + + elif path.parts[1] == "static": + content = wheel.read(filename) + outfilename = static_path / package_name / Path(*path.parts[2:]) + outfilename.parent.mkdir(parents=True, exist_ok=True) + with outfilename.open("wb") as f: + f.write(content) + + def _write_pyscript_toml(self, app: AppConfig): + """Write the ``pyscript.toml`` file for the app. + + :param app: The application whose ``pyscript.toml`` is being written. + """ + with (self.project_path(app) / "pyscript.toml").open("wb") as f: + config = { + "name": app.formal_name, + "description": app.description, + "version": app.version, + "splashscreen": {"autoclose": True}, + "terminal": False, + # Ensure that we're using Unix path separators, as the content + # will be parsed by pyscript in the browser. + "packages": [ + f'/{"/".join(wheel.relative_to(self.project_path(app)).parts)}' + for wheel in sorted(self.wheel_path(app).glob("*.whl")) + ], + } + # Parse any additional pyscript.toml content, and merge it into + # the overall content + try: + extra = tomllib.loads(app.extra_pyscript_toml_content) + config.update(extra) + except tomllib.TOMLDecodeError as e: + raise BriefcaseConfigError( + f"Extra pyscript.toml content isn't valid TOML: {e}" + ) from e + except AttributeError: + pass + + # Write the final configuration. + tomli_w.dump(config, f) + + def _write_bootstrap(self, app: AppConfig, test_mode: bool): + """Write the bootstrap code for the app. + + :param app: The app whose base inserts we need. + :param test_mode: Boolean; Is the app running in test mode? + """ + # Construct the bootstrap script. + bootstrap = [ + "import runpy", + "", + f"# Run {app.formal_name}'s main module", + f'runpy.run_module("{app.module_name}", run_name="__main__", alter_sys=True)', + ] + if test_mode: + bootstrap.extend( + [ + "", + f"# Run {app.formal_name}'s test module", + f'runpy.run_module("tests.{app.module_name}", run_name="__main__", alter_sys=True)', + ] + ) + + with (self.project_path(app) / "main.py").open("w") as f: + f.write("\n".join(bootstrap)) + + def _merge_insert_content(self, inserts, key, path): + """Merge multi-file insert content into a single insert. + + Rewrites the inserts, removing the entry for ``key``, + producing a merged entry for ``path`` that has a single + ``key`` insert. + + This is used to merge multiple contributed CSS files into + a single CSS insert. - def build_app(self, app: AppConfig, **kwargs): + :param inserts: The full set of inserts + :param key: The key to merge + :param path: The path for the merged insert. + """ + try: + original = inserts.pop(key) + except KeyError: + # Nothing to merge. + pass + else: + merged = {} + for filename, package_inserts in original.items(): + for package, css in package_inserts.items(): + try: + old_css = merged[package] + except KeyError: + old_css = "" + + full_css = f"{old_css}/********** {filename} **********/\n{css}\n" + merged[package] = full_css + + # Preserve the merged content as a single insert + inserts[path] = {key: merged} + + def _write_inserts(self, app: AppConfig, filename: Path, inserts: dict): + """Write inserts into an existing file. + + This looks for start and end markers in the named file, and replaces the + content inside those markers with the inserted content. + + Multiple formats of insert marker are inspected, to accommodate HTML, + Python and CSS/JS comment conventions: + * HTML: ```` and ```` + * Python: ``#####@ insert:start @#####\n`` and ``######@ insert:end @#####\n`` + * CSS/JS: ``/*****@ insert:end @*****/`` and ``/*****@ insert:end @*****/`` + + :param app: The application whose ``pyscript.toml`` is being written. + :param filename: The file whose insert is to be written. + :param inserts: The inserts for the file. A 2 level dictionary, keyed by + the name of the insert to add, and then package that contributed the + insert. + """ + # Read the current content + with (self.project_path(app) / filename).open() as f: + content = f.read() + + for insert, packages in inserts.items(): + for comment, marker, replacement in [ + # HTML + ( + ( + "\n" + "{content}" + ), + r".*?", + r"\n{content}", + ), + # CSS/JS + ( + ( + "/**************************************************\n" + " * {package}\n" + " *************************************************/\n" + "{content}" + ), + r"/\*\*\*\*\*@ {insert}:start @\*\*\*\*\*/.*?/\*\*\*\*\*@ {insert}:end @\*\*\*\*\*/", + r"/*****@ {insert}:start @*****/\n{content}/*****@ {insert}:end @*****/", + ), + # Python + ( + ( + "##################################################\n" + "# {package}\n" + "##################################################\n" + "{content}" + ), + r"#####@ {insert}:start @#####\n.*?#####@ {insert}:end @#####", + r"#####@ {insert}:start @#####\n{content}\n#####@ {insert}:end @#####", + ), + ]: + full_insert = "\n".join( + comment.format(package=package, content=content) + for package, content in packages.items() + ) + content = re.sub( + marker.format(insert=insert), + replacement.format(insert=insert, content=full_insert), + content, + flags=re.MULTILINE | re.DOTALL, + ) + + # Write the new index.html + with (self.project_path(app) / filename).open("w") as f: + f.write(content) + + def build_app(self, app: AppConfig, test_mode: bool = False, **kwargs): """Build the static web deployment for the application. :param app: The application to build + :param test_mode: Boolean; Is the app running in test mode? """ self.logger.info("Building web project...", prefix=app.app_name) @@ -164,13 +400,19 @@ def build_app(self, app: AppConfig, **kwargs): "utf8", "-m", "pip", - "wheel", - "--wheel-dir", + "download", + "--platform", + "emscripten_3_1_32_wasm32", + "--only-binary=:all:", + "--extra-index-url", + "https://pyodide-pypi-api.s3.amazonaws.com/simple/", + "-d", self.wheel_path(app), "-r", self.bundle_path(app) / "requirements.txt", ], check=True, + cwd=self.bundle_path(app), encoding="UTF-8", ) except subprocess.CalledProcessError as e: @@ -179,51 +421,33 @@ def build_app(self, app: AppConfig, **kwargs): ) from e with self.input.wait_bar("Writing Pyscript configuration file..."): - with (self.project_path(app) / "pyscript.toml").open("wb") as f: - config = { - "name": app.formal_name, - "description": app.description, - "version": app.version, - "splashscreen": {"autoclose": True}, - "terminal": False, - # Ensure that we're using Unix path separators, as the content - # will be parsed by pyscript in the browser. - "packages": [ - f'/{"/".join(wheel.relative_to(self.project_path(app)).parts)}' - for wheel in sorted(self.wheel_path(app).glob("*.whl")) - ], - } - # Parse any additional pyscript.toml content, and merge it into - # the overall content - try: - extra = tomllib.loads(app.extra_pyscript_toml_content) - config.update(extra) - except tomllib.TOMLDecodeError as e: - raise BriefcaseConfigError( - f"Extra pyscript.toml content isn't valid TOML: {e}" - ) from e - except AttributeError: - pass - - # Write the final configuration. - tomli_w.dump(config, f) - - self.logger.info("Compile static web content from wheels") - with self.input.wait_bar("Compiling static web content from wheels..."): - # Trim previously compiled content out of briefcase.css - briefcase_css_path = ( - self.project_path(app) / "static" / "css" / "briefcase.css" - ) - self._trim_file( - briefcase_css_path, - sentinel=" ******************* Wheel contributed styles **********************/", - ) + self._write_pyscript_toml(app) + + inserts = {} - # Extract static resources from packaged wheels + self.logger.info("Compile contributed content from wheels") + with self.input.wait_bar("Compiling contributed content from wheels..."): + # Extract insert and static resources from packaged wheels for wheelfile in sorted(self.wheel_path(app).glob("*.whl")): self.logger.info(f" Processing {wheelfile.name}...") - with briefcase_css_path.open("a", encoding="utf-8") as css_file: - self._process_wheel(wheelfile, css_file=css_file) + self._process_wheel( + wheelfile, + inserts=inserts, + static_path=self.static_path(app), + ) + + # Reorganize CSS content so that there's a single content insert + # for all contributed packages + self._merge_insert_content(inserts, "CSS", "static/css/briefcase.css") + + self._write_bootstrap(app, test_mode=test_mode) + + # Add content inserts to the site content. + self.logger.info("Add content inserts") + with self.input.wait_bar("Adding content inserts..."): + for filename, file_inserts in inserts.items(): + self.logger.info(f" Processing {filename}...") + self._write_inserts(app, filename=filename, inserts=file_inserts) return {} @@ -251,16 +475,23 @@ def end_headers(self): def log_message(self, format: str, *args: Any) -> None: message = (format % args).translate(self._control_char_table) - self.server.logger.info( - f"{self.address_string()} - - [{self.log_date_time_string()}] {message}" - ) + if self.server.logger: + self.server.logger.info( + f"{self.address_string()} - - [{self.log_date_time_string()}] {message}" + ) class LocalHTTPServer(ThreadingHTTPServer): """An HTTP server that serves local static content.""" def __init__( - self, base_path, host, port, RequestHandlerClass=HTTPHandler, *, logger: Log + self, + base_path, + host, + port, + RequestHandlerClass=HTTPHandler, + *, + logger: Log, ): self.base_path = base_path self.logger = logger @@ -270,6 +501,10 @@ def __init__( class StaticWebRunCommand(StaticWebMixin, RunCommand): description = "Run a static web project." + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.playwright = sync_playwright + def add_options(self, parser): super().add_options(parser) parser.add_argument( @@ -313,9 +548,6 @@ def run_app( :param port: The port on which to run the server :param open_browser: Should a browser be opened on the newly started server. """ - if test_mode: - raise BriefcaseCommandError("Briefcase can't run web apps in test mode.") - self.logger.info("Starting web server...", prefix=app.app_name) # At least for now, there's no easy way to pass arguments to a web app. @@ -324,12 +556,14 @@ def run_app( httpd = None try: - # Create a local HTTP server + # Create a local HTTP server. + # Don't log the server if we're in test mode; + # otherwise, log server activity to the console httpd = LocalHTTPServer( self.project_path(app), host=host, port=port, - logger=self.logger, + logger=None if test_mode else self.logger, ) # Extract the host and port from the server. This is needed @@ -337,19 +571,90 @@ def run_app( host, port = httpd.socket.getsockname() url = f"http://{host}:{port}" - self.logger.info(f"Web server open on {url}") - # If requested, open a browser tab on the newly opened server. - if open_browser: - webbrowser.open_new_tab(url) + if test_mode: + # Ensure that the Chromium Playwright browser is installed + # This is a no-output, near no-op if the browser *is* installed; + # If it isn't, it shows a download progress bar. + self.tools.subprocess.run( + ["playwright", "install", "chromium"], + stream_output=False, + ) - self.logger.info( - "Web server log output (type CTRL-C to stop log)...", - prefix=app.app_name, - ) - self.logger.info("=" * 75) + # Start the web server in a background thread + server_thread = Thread(target=httpd.serve_forever) + server_thread.start() + + self.logger.info("Running test suite...") + self.logger.info("=" * 75) + + # Open a Playwright session + with self.playwright() as playwright: + browser = playwright.chromium.launch(headless=not open_browser) + page = browser.new_page() + + # Install a handler that will capture every line of + # log content in a buffer. + buffer = [] + page.on("console", lambda msg: buffer.append(msg.text)) + + # Load the test page. + page.goto(url) + + # Build a log filter looking for test suite termination + log_filter = LogFilter( + clean_filter=None, + clean_output=True, + exit_filter=LogFilter.test_filter( + getattr(app, "exit_regex", LogFilter.DEFAULT_EXIT_REGEX) + ), + ) + try: + while True: + # Process all the lines in the accumulated log buffer, + # looking for the termination condition. Finding the + # termination condition is what stops the test suite. + for line in buffer: + for filtered in log_filter(line): + self.logger.info(filtered) + buffer = [] + + # Insert a short pause so that Playwright can + # generate the next batch of console logs + page.wait_for_timeout(100) + except StopStreaming: + if log_filter.returncode == 0: + self.logger.info("Test suite passed!", prefix=app.app_name) + else: + if log_filter.returncode is None: + raise BriefcaseCommandError( + "Test suite didn't report a result." + ) + else: + self.logger.error( + "Test suite failed!", prefix=app.app_name + ) + raise BriefcaseTestSuiteFailure() + finally: + # Close the Playwright browser, and shut down the web server + browser.close() + httpd.shutdown() + else: + # Normal execution mode + self.logger.info(f"Web server open on {url}") + + # If requested, open a browser tab on the newly opened server. + if open_browser: + webbrowser.open_new_tab(url) + + self.logger.info( + "Web server log output (type CTRL-C to stop log)...", + prefix=app.app_name, + ) + self.logger.info("=" * 75) + + # Start the web server in blocking mode. + httpd.serve_forever() - # Run the server. - httpd.serve_forever() except PermissionError as e: if port < 1024: raise BriefcaseCommandError( diff --git a/tests/commands/run/test_LogFilter.py b/tests/commands/run/test_LogFilter.py index 869881a54..1b76a23ea 100644 --- a/tests/commands/run/test_LogFilter.py +++ b/tests/commands/run/test_LogFilter.py @@ -1,5 +1,3 @@ -from unittest import mock - import pytest from briefcase.commands.run import LogFilter @@ -8,9 +6,7 @@ def test_default_filter(): """A default logfilter echoes content verbatim.""" - popen = mock.MagicMock() log_filter = LogFilter( - popen, clean_filter=None, clean_output=True, exit_filter=None, @@ -34,9 +30,7 @@ def test_clean_filter(): def clean_filter(line): return line[5:], True - popen = mock.MagicMock() log_filter = LogFilter( - popen, clean_filter=clean_filter, clean_output=True, exit_filter=None, @@ -61,9 +55,7 @@ def test_clean_filter_unclean_output(): def clean_filter(line): return line[5:], True - popen = mock.MagicMock() log_filter = LogFilter( - popen, clean_filter=clean_filter, clean_output=False, exit_filter=None, @@ -228,9 +220,7 @@ def clean_filter(line): exit_filter = LogFilter.test_filter(r"^-----\n\nEXIT (?P\d+)$") # Set up a log stream - popen = mock.MagicMock() log_filter = LogFilter( - popen, clean_filter=clean_filter if use_content_filter else None, clean_output=clean_output, exit_filter=exit_filter, diff --git a/tests/platforms/web/static/conftest.py b/tests/platforms/web/static/conftest.py index 8500ad30f..45ccd4659 100644 --- a/tests/platforms/web/static/conftest.py +++ b/tests/platforms/web/static/conftest.py @@ -17,7 +17,24 @@ def first_app_generated(first_app_config, tmp_path): ) # Create index.html - create_file(bundle_path / "www" / "index.html", "") + create_file( + bundle_path / "www" / "index.html", + """ + + + + + + + +#####@ bootstrap:start @##### +#####@ bootstrap:end @##### + + + + +""", + ) # Create the initial briefcase.css create_file( @@ -26,8 +43,8 @@ def first_app_generated(first_app_config, tmp_path): #pyconsole { display: None; } -/******************************************************************* - ******************** Wheel contributed styles ********************/ +/*****@ CSS:start @*****/ +/*****@ CSS:end @*****/ """, ) diff --git a/tests/platforms/web/static/test_build.py b/tests/platforms/web/static/test_build.py index 34ab256e5..d92cb2002 100644 --- a/tests/platforms/web/static/test_build.py +++ b/tests/platforms/web/static/test_build.py @@ -43,7 +43,12 @@ def mock_run(*args, **kwargs): bundle_path / "www" / "static" / "wheels", "first_app", extra_content=[ - ("dependency/static/style.css", "span { margin: 10px; }\n"), + ("dependency/inserts/style.css", "span { margin: 10px; }\n"), + ("dependency/inserts/main.css", "span { padding: 10px; }\n"), + ( + "dependency/inserts/index.html:header", + "style.css\n", + ), ], ), elif args[0][5] == "pip": @@ -51,14 +56,14 @@ def mock_run(*args, **kwargs): bundle_path / "www" / "static" / "wheels", "dependency", extra_content=[ - ("dependency/static/style.css", "div { margin: 10px; }\n"), + ("dependency/inserts/dependency.css", "div { margin: 20px; }\n"), ], ), create_wheel( bundle_path / "www" / "static" / "wheels", "other", extra_content=[ - ("other/static/style.css", "div { padding: 10px; }\n"), + ("other/inserts/style.css", "div { padding: 30px; }\n"), ], ), else: @@ -105,13 +110,19 @@ def mock_run(*args, **kwargs): "utf8", "-m", "pip", - "wheel", - "--wheel-dir", + "download", + "--platform", + "emscripten_3_1_32_wasm32", + "--only-binary=:all:", + "--extra-index-url", + "https://pyodide-pypi-api.s3.amazonaws.com/simple/", + "-d", bundle_path / "www" / "static" / "wheels", "-r", bundle_path / "requirements.txt", ], check=True, + cwd=bundle_path, encoding="UTF-8", ), ] @@ -131,7 +142,7 @@ def mock_run(*args, **kwargs): ], } - # briefcase.css has been appended + # briefcase.css has been customized with (bundle_path / "www" / "static" / "css" / "briefcase.css").open( encoding="utf-8" ) as f: @@ -143,31 +154,241 @@ def mock_run(*args, **kwargs): "#pyconsole {", " display: None;", "}", - "/*******************************************************************", - " ******************** Wheel contributed styles ********************/", + "/*****@ CSS:start @*****/", + "/**************************************************", + " * dependency 1.2.3", + " *************************************************/", + "/********** dependency.css **********/", + "div { margin: 20px; }", + "", + "", + "/**************************************************", + " * first_app 1.2.3", + " *************************************************/", + "/********** style.css **********/", + "span { margin: 10px; }", "", - "/*******************************************************", - " * dependency 1.2.3::style.css", - " *******************************************************/", + "/********** main.css **********/", + "span { padding: 10px; }", "", - "div { margin: 10px; }", "", - "/*******************************************************", - " * first_app 1.2.3::style.css", - " *******************************************************/", + "/**************************************************", + " * other 1.2.3", + " *************************************************/", + "/********** style.css **********/", + "div { padding: 30px; }", "", + "/*****@ CSS:end @*****/", + ] + ) + + "\n" + ) + + # index.html has been customized + with (bundle_path / "www" / "index.html").open(encoding="utf-8") as f: + assert f.read() == "\n".join( + [ + "", + "", + " ", + "", + "", + "style.css", + "", + " ", + " ", + " ", + "#####@ bootstrap:start @#####", + "#####@ bootstrap:end @#####", + " ", + "", + " ", + "", + "", + ] + ) + + +def test_build_app_test_mode(build_command, first_app_generated, tmp_path): + """An app can be built in test mode.""" + bundle_path = tmp_path / "base_path" / "build" / "first-app" / "web" / "static" + + # Invoking build will create wheels as a side effect. + def mock_run(*args, **kwargs): + if args[0][5] == "wheel": + create_wheel( + bundle_path / "www" / "static" / "wheels", + "first_app", + extra_content=[ + ("dependency/inserts/style.css", "span { margin: 10px; }\n"), + ("dependency/inserts/main.css", "span { padding: 10px; }\n"), + ( + "dependency/inserts/index.html:header", + "style.css\n", + ), + ], + ), + elif args[0][5] == "pip": + create_wheel( + bundle_path / "www" / "static" / "wheels", + "dependency", + extra_content=[ + ("dependency/inserts/dependency.css", "div { margin: 20px; }\n"), + ], + ), + create_wheel( + bundle_path / "www" / "static" / "wheels", + "other", + extra_content=[ + ("other/inserts/style.css", "div { padding: 30px; }\n"), + ], + ), + else: + raise ValueError("Unknown command") + + build_command.tools.subprocess.run.side_effect = mock_run + + # Mock the side effect of invoking shutil + build_command.tools.shutil.rmtree.side_effect = lambda *args: shutil.rmtree( + bundle_path / "www" / "static" / "wheels" + ) + + # Build the web app. + build_command.build_app(first_app_generated, test_mode=True) + + # The old wheel folder was removed + build_command.tools.shutil.rmtree.assert_called_once_with( + bundle_path / "www" / "static" / "wheels" + ) + + # `wheel pack` and `pip wheel` was invoked + assert build_command.tools.subprocess.run.mock_calls == [ + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "wheel", + "pack", + bundle_path / "app", + "--dest-dir", + bundle_path / "www" / "static" / "wheels", + ], + check=True, + encoding="UTF-8", + ), + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "download", + "--platform", + "emscripten_3_1_32_wasm32", + "--only-binary=:all:", + "--extra-index-url", + "https://pyodide-pypi-api.s3.amazonaws.com/simple/", + "-d", + bundle_path / "www" / "static" / "wheels", + "-r", + bundle_path / "requirements.txt", + ], + check=True, + cwd=bundle_path, + encoding="UTF-8", + ), + ] + + # Pyscript.toml has been written + with (bundle_path / "www" / "pyscript.toml").open("rb") as f: + assert tomllib.load(f) == { + "name": "First App", + "description": "The first simple app \\ demonstration", + "version": "0.0.1", + "splashscreen": {"autoclose": True}, + "terminal": False, + "packages": [ + "/static/wheels/dependency-1.2.3-py3-none-any.whl", + "/static/wheels/first_app-1.2.3-py3-none-any.whl", + "/static/wheels/other-1.2.3-py3-none-any.whl", + ], + } + + # briefcase.css has been customized + with (bundle_path / "www" / "static" / "css" / "briefcase.css").open( + encoding="utf-8" + ) as f: + assert ( + f.read() + == "\n".join( + [ + "", + "#pyconsole {", + " display: None;", + "}", + "/*****@ CSS:start @*****/", + "/**************************************************", + " * dependency 1.2.3", + " *************************************************/", + "/********** dependency.css **********/", + "div { margin: 20px; }", + "", + "", + "/**************************************************", + " * first_app 1.2.3", + " *************************************************/", + "/********** style.css **********/", "span { margin: 10px; }", "", - "/*******************************************************", - " * other 1.2.3::style.css", - " *******************************************************/", + "/********** main.css **********/", + "span { padding: 10px; }", "", - "div { padding: 10px; }", + "", + "/**************************************************", + " * other 1.2.3", + " *************************************************/", + "/********** style.css **********/", + "div { padding: 30px; }", + "", + "/*****@ CSS:end @*****/", ] ) + "\n" ) + # index.html has been customized + with (bundle_path / "www" / "index.html").open(encoding="utf-8") as f: + assert f.read() == "\n".join( + [ + "", + "", + " ", + "", + "", + "style.css", + "", + " ", + " ", + " ", + "#####@ bootstrap:start @#####", + "#####@ bootstrap:end @#####", + " ", + "", + " ", + "", + "", + ] + ) + def test_build_app_custom_pyscript_toml(build_command, first_app_generated, tmp_path): """An app with extra pyscript.toml content can be written.""" @@ -275,13 +496,19 @@ def test_build_app_missing_wheel_dir(build_command, first_app_generated, tmp_pat "utf8", "-m", "pip", - "wheel", - "--wheel-dir", + "download", + "--platform", + "emscripten_3_1_32_wasm32", + "--only-binary=:all:", + "--extra-index-url", + "https://pyodide-pypi-api.s3.amazonaws.com/simple/", + "-d", bundle_path / "www" / "static" / "wheels", "-r", bundle_path / "requirements.txt", ], check=True, + cwd=bundle_path, encoding="UTF-8", ), ] @@ -308,7 +535,7 @@ def mock_run(*args, **kwargs): bundle_path / "www" / "static" / "wheels", "first_app", extra_content=[ - ("dependency/static/style.css", "span { margin: 10px; }\n"), + ("dependency/inserts/style.css", "span { margin: 10px; }\n"), ], ), elif args[0][5] == "pip": @@ -357,13 +584,19 @@ def mock_run(*args, **kwargs): "utf8", "-m", "pip", - "wheel", - "--wheel-dir", + "download", + "--platform", + "emscripten_3_1_32_wasm32", + "--only-binary=:all:", + "--extra-index-url", + "https://pyodide-pypi-api.s3.amazonaws.com/simple/", + "-d", bundle_path / "www" / "static" / "wheels", "-r", bundle_path / "requirements.txt", ], check=True, + cwd=bundle_path, encoding="UTF-8", ), ] @@ -393,14 +626,14 @@ def mock_run(*args, **kwargs): "#pyconsole {", " display: None;", "}", - "/*******************************************************************", - " ******************** Wheel contributed styles ********************/", - "", - "/*******************************************************", - " * first_app 1.2.3::style.css", - " *******************************************************/", - "", + "/*****@ CSS:start @*****/", + "/**************************************************", + " * first_app 1.2.3", + " *************************************************/", + "/********** style.css **********/", "span { margin: 10px; }", + "", + "/*****@ CSS:end @*****/", ] ) + "\n" @@ -515,13 +748,19 @@ def test_dependency_fail(build_command, first_app_generated, tmp_path): "utf8", "-m", "pip", - "wheel", - "--wheel-dir", + "download", + "--platform", + "emscripten_3_1_32_wasm32", + "--only-binary=:all:", + "--extra-index-url", + "https://pyodide-pypi-api.s3.amazonaws.com/simple/", + "-d", bundle_path / "www" / "static" / "wheels", "-r", bundle_path / "requirements.txt", ], check=True, + cwd=bundle_path, encoding="UTF-8", ), ] diff --git a/tests/platforms/web/static/test_build__process_wheel.py b/tests/platforms/web/static/test_build__process_wheel.py index 36e8ef77f..7d667a5d8 100644 --- a/tests/platforms/web/static/test_build__process_wheel.py +++ b/tests/platforms/web/static/test_build__process_wheel.py @@ -1,11 +1,9 @@ -from io import StringIO - import pytest from briefcase.console import Console, Log from briefcase.platforms.web.static import StaticWebBuildCommand -from ....utils import create_wheel +from ....utils import create_file, create_wheel @pytest.fixture @@ -19,66 +17,118 @@ def build_command(tmp_path): def test_process_wheel(build_command, tmp_path): - """A wheel can be processed to have CSS content extracted.""" + """Wheels can have inserted and static content extracted.""" + # Create an existing file from a previous unpack + create_file( + tmp_path / "static" / "dummy" / "old" / "existing.css", + "div.existing {margin: 99px}", + ) # Create a wheel with some content. wheel_filename = create_wheel( tmp_path, + package="dummy", extra_content=[ - # Two CSS files - ( - "dummy/static/first.css", - "span {\n font-color: red;\n font-size: larger\n}\n", - ), - ("dummy/static/second.css", "div {\n padding: 10px\n}\n"), - ("dummy/static/deep/third.css", "p {\n color: red\n}\n"), - # Content in the static file that isn't CSS - ("dummy/static/explosions.js", "alert('boom!');"), - # CSS in a location that isn't the static folder. - ("dummy/other.css", "div.other {\n margin: 10px\n}\n"), - ("lost.css", "div.lost {\n margin: 10px\n}\n"), + # Three CSS files + ("dummy/inserts/first.css", "div.first {\n margin: 1px\n}\n"), + ("dummy/inserts/second.css", "div.second {\n margin: 2px\n}\n"), + ("dummy/inserts/deep/third.css", "div.third {\n margin: 3px\n}\n"), + # Non-CSS insert content + ("dummy/inserts/index.html:header", ""), + ("dummy/inserts/deep/index.html:other", ""), + # CSS, JS and images in the static folder. + ("dummy/static/other.css", "div.other {\n margin: 10px\n}\n"), + ("dummy/static/deep/more.css", "div.more {\n margin: 11px\n}\n"), + # CSS in a location that isn't the static or inserts folder. + ("dummy/somewhere/somewhere.css", "div.somewhere {\n margin: 20px\n}\n"), + ("dummy/extra.css", "div.extra {\n margin: 21px\n}\n"), + ("lost.css", "div.lost {\n margin: 22px\n}\n"), + ], + ) + + inserts = {} + build_command._process_wheel( + wheel_filename, + inserts=inserts, + static_path=tmp_path / "static", + ) + + assert inserts == { + "CSS": { + "first.css": {"dummy 1.2.3": "div.first {\n margin: 1px\n}\n"}, + "second.css": {"dummy 1.2.3": "div.second {\n margin: 2px\n}\n"}, + "deep/third.css": {"dummy 1.2.3": "div.third {\n margin: 3px\n}\n"}, + }, + "index.html": { + "header": {"dummy 1.2.3": ""}, + }, + "deep/index.html": { + "other": {"dummy 1.2.3": ""}, + }, + } + + # Create another wheel with some content. + wheel_filename = create_wheel( + tmp_path, + package="more", + extra_content=[ + # A new CSS insert + ("more/inserts/more.css", "div.more {\n margin: 30px\n}\n"), + # Another CSS insert with the same name + ("more/inserts/first.css", "div.first {\n margin: 31px\n}\n"), + # A new insert with a new name + ("more/inserts/other.html:header", ""), + # A new insert on an existing file + ("more/inserts/index.html:bootstrap", "hello"), + # An existing insert on an existing file + ("more/inserts/index.html:header", ""), + # CSS, JS and images in the static folder. + ("more/static/more-other.css", "div.other {\n margin: 10px\n}\n"), + ("more/static/deep/more-more.css", "div.more {\n margin: 11px\n}\n"), ], ) - # Create a dummy css file - css_file = StringIO() - - build_command._process_wheel(wheel_filename, css_file=css_file) - - assert ( - css_file.getvalue() - == "\n".join( - [ - "", - "/*******************************************************", - " * dummy 1.2.3::first.css", - " *******************************************************/", - "", - "span {", - " font-color: red;", - " font-size: larger", - "}", - "", - "/*******************************************************", - " * dummy 1.2.3::second.css", - " *******************************************************/", - "", - "div {", - " padding: 10px", - "}", - "", - "/*******************************************************", - " * dummy 1.2.3::deep/third.css", - " *******************************************************/", - "", - "p {", - " color: red", - "}", - ] - ) - + "\n" + # Process the additional wheel over the existing wheel + build_command._process_wheel( + wheel_filename, + inserts=inserts, + static_path=tmp_path / "static", ) + assert inserts == { + "CSS": { + "first.css": { + "dummy 1.2.3": "div.first {\n margin: 1px\n}\n", + "more 1.2.3": "div.first {\n margin: 31px\n}\n", + }, + "second.css": {"dummy 1.2.3": "div.second {\n margin: 2px\n}\n"}, + "deep/third.css": {"dummy 1.2.3": "div.third {\n margin: 3px\n}\n"}, + "more.css": {"more 1.2.3": "div.more {\n margin: 30px\n}\n"}, + }, + "index.html": { + "header": { + "dummy 1.2.3": "", + "more 1.2.3": "", + }, + "bootstrap": {"more 1.2.3": "hello"}, + }, + "other.html": { + "header": {"more 1.2.3": ""}, + }, + "deep/index.html": { + "other": {"dummy 1.2.3": ""}, + }, + } + + # Static files all exist in the static location + assert (tmp_path / "static" / "dummy" / "other.css").exists() + assert (tmp_path / "static" / "dummy" / "deep" / "more.css").exists() + assert (tmp_path / "static" / "more" / "more-other.css").exists() + assert (tmp_path / "static" / "more" / "deep" / "more-more.css").exists() + + # Pre-existing static file no longer exists. + assert not (tmp_path / "static" / "dummy" / "old" / "existing.css").exists() + def test_process_wheel_no_content(build_command, tmp_path): """A wheel with no resources can be processed.""" @@ -87,17 +137,15 @@ def test_process_wheel_no_content(build_command, tmp_path): wheel_filename = create_wheel( tmp_path, extra_content=[ - # Content in the static file that isn't CSS - ("dummy/static/explosions.js", "alert('boom!');"), # CSS in a location that isn't the static folder. ("dummy/other.css", "div.other {\n margin: 10px\n}\n"), ("lost.css", "div.lost {\n margin: 10px\n}\n"), ], ) - # Create a dummy css file - css_file = StringIO() - - build_command._process_wheel(wheel_filename, css_file=css_file) + inserts = {} + build_command._process_wheel( + wheel_filename, inserts=inserts, static_path=tmp_path / "static" + ) - assert css_file.getvalue() == "" + assert inserts == {} diff --git a/tests/platforms/web/static/test_build__trim_file.py b/tests/platforms/web/static/test_build__trim_file.py deleted file mode 100644 index 7557f62ba..000000000 --- a/tests/platforms/web/static/test_build__trim_file.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytest - -from briefcase.console import Console, Log -from briefcase.platforms.web.static import StaticWebBuildCommand - -from ....utils import create_file - - -@pytest.fixture -def build_command(tmp_path): - return StaticWebBuildCommand( - logger=Log(), - console=Console(), - base_path=tmp_path / "base_path", - data_path=tmp_path / "briefcase", - ) - - -def test_trim_file(build_command, tmp_path): - """A file can be trimmed at a sentinel.""" - filename = tmp_path / "dummy.txt" - content = [ - "This is before the sentinel.", - "This is also before the sentinel.", - " ** This is the sentinel ** ", - "This is after the sentinel.", - "This is also after the sentinel.", - ] - - create_file(filename, "\n".join(content)) - - # Trim the file at the sentinel - build_command._trim_file(filename, sentinel=" ** This is the sentinel ** ") - - # The file contains everything up to and including the sentinel. - with filename.open(encoding="utf-8") as f: - assert f.read() == "\n".join(content[:3]) + "\n" - - -def test_trim_no_sentinel(build_command, tmp_path): - """A file that doesn't contain the sentinel is returned as-is.""" - filename = tmp_path / "dummy.txt" - content = [ - "This is before the sentinel.", - "This is also before the sentinel.", - "NO SENTINEL HERE", - "This is after the sentinel.", - "This is also after the sentinel.", - ] - - create_file(filename, "\n".join(content)) - - # Trim the file at a sentinel - build_command._trim_file(filename, sentinel=" ** This is the sentinel ** ") - - # The file is unmodified. - with filename.open(encoding="utf-8") as f: - assert f.read() == "\n".join(content) - - -def test_trim_file_multiple_sentinels(build_command, tmp_path): - """A file with multiple sentinels is trimmed at the first one.""" - filename = tmp_path / "dummy.txt" - content = [ - "This is before the sentinel.", - "This is also before the sentinel.", - " ** This is the sentinel ** ", - "This is after the first sentinel.", - "This is also after the first sentinel.", - " ** This is the sentinel ** ", - "This is after the second sentinel.", - "This is also after the second sentinel.", - ] - - create_file(filename, "\n".join(content)) - - # Trim the file at the sentinel - build_command._trim_file(filename, sentinel=" ** This is the sentinel ** ") - - # The file contains everything up to and including the sentinel. - with filename.open(encoding="utf-8") as f: - assert f.read() == "\n".join(content[:3]) + "\n" - - -def test_trim_sentinel_last_line(build_command, tmp_path): - """A file with the sentinel as the last full line isn't a problem.""" - filename = tmp_path / "dummy.txt" - content = [ - "This is before the sentinel.", - "This is also before the sentinel.", - " ** This is the sentinel ** ", - ] - - create_file(filename, "\n".join(content) + "\n") - - # Trim the file at a sentinel - build_command._trim_file(filename, sentinel=" ** This is the sentinel ** ") - - # The file is unmodified. - with filename.open(encoding="utf-8") as f: - assert f.read() == "\n".join(content) + "\n" - - -def test_trim_sentinel_EOF(build_command, tmp_path): - """A file with the sentinel at EOF isn't a problem.""" - filename = tmp_path / "dummy.txt" - content = [ - "This is before the sentinel.", - "This is also before the sentinel.", - " ** This is the sentinel ** ", - ] - - create_file(filename, "\n".join(content)) - - # Trim the file at a sentinel - build_command._trim_file(filename, sentinel=" ** This is the sentinel ** ") - - # The file is unmodified. - with filename.open(encoding="utf-8") as f: - assert f.read() == "\n".join(content) diff --git a/tests/platforms/web/static/test_run.py b/tests/platforms/web/static/test_run.py index 04e54f1db..3c2f75be9 100644 --- a/tests/platforms/web/static/test_run.py +++ b/tests/platforms/web/static/test_run.py @@ -1,12 +1,14 @@ import errno import webbrowser from http.server import HTTPServer, SimpleHTTPRequestHandler +from threading import Event from unittest import mock import pytest from briefcase.console import Console, Log -from briefcase.exceptions import BriefcaseCommandError +from briefcase.exceptions import BriefcaseCommandError, BriefcaseTestSuiteFailure +from briefcase.integrations.subprocess import StopStreaming from briefcase.platforms.web.static import ( HTTPHandler, LocalHTTPServer, @@ -487,19 +489,298 @@ def test_log_requests_to_logger(monkeypatch): monkeypatch.setattr( SimpleHTTPRequestHandler, "handle", mock.Mock(return_value=None) ) + + # Mock a server server = mock.MagicMock() + handler = HTTPHandler(mock.MagicMock(), ("localhost", 8080), server) handler.log_date_time_string = mock.Mock(return_value="now") handler.log_message("hello\033") server.logger.info.assert_called_once_with("localhost - - [now] hello\\x1b") -def test_test_mode(run_command, first_app_built): - """Test mode raises an error (at least for now).""" +def test_log_requests_without_logger(monkeypatch): + """If there's no logger, the request handler discards server log messages.""" + monkeypatch.setattr( + SimpleHTTPRequestHandler, "handle", mock.Mock(return_value=None) + ) + + # Mock a server without a logger. + server = mock.MagicMock() + server.logger = None + + handler = HTTPHandler(mock.MagicMock(), ("localhost", 8080), server) + handler.log_date_time_string = mock.Mock(return_value="now") + handler.log_message("hello\033") + + # This is a no-op. There's nothing to verify; we just need to validate that + # we can call log_message when there's no logger set. + + +def test_run_test_mode(monkeypatch, run_command, first_app_built): + """A static web app can be launched in test mode.""" + # Mock server creation + mock_server_init = mock.MagicMock(spec_set=HTTPServer) + monkeypatch.setattr(HTTPServer, "__init__", mock_server_init) + + # Mock the socket name returned by the server. + socket = mock.MagicMock() + socket.getsockname.return_value = ("127.0.0.1", "8080") + LocalHTTPServer.socket = socket + + # Mock playwright + mock_playwright = mock.MagicMock() + run_command.playwright = mock_playwright + + mock_playwright_instance = mock.MagicMock() + mock_playwright.return_value = mock_playwright_instance + + mock_playwright_session = mock.MagicMock() + mock_playwright_instance.__enter__.return_value = mock_playwright_session + + mock_browser = mock.MagicMock() + mock_playwright_session.chromium.launch.return_value = mock_browser + + mock_page = mock.MagicMock() + mock_browser.new_page.return_value = mock_page + + # Inject a single log line indicating test success into the mocked web + # console. This is done as a side effect of the `goto` call; we then inspect + # backwards to get the argument that was passed to the `page.on()` call to + # get the console logger. This has the effect of injecting content into + # the logger that will stop the loop after the first iteration. + def fill_buffer(url): + console = mock_page.on.mock_calls[-1].args[1] + msg = mock.Mock() + for i in range(1, 100): + msg.text = f"Test suite is running [{i}%]" + console(msg) + + msg.text = ">>>>>>>>>> EXIT 0 <<<<<<<<<<" + console(msg) + + mock_page.goto.side_effect = fill_buffer + + # Mock subprocess + run_command.tools.subprocess = mock.MagicMock() + + # Mock server execution, raising a user exit. + shutdown_event = Event() + + mock_serve_forever = mock.MagicMock( + side_effect=lambda: shutdown_event.wait(timeout=2) + ) + monkeypatch.setattr(HTTPServer, "serve_forever", mock_serve_forever) + + # Mock shutdown + mock_shutdown = mock.MagicMock(side_effect=lambda: shutdown_event.set()) + monkeypatch.setattr(HTTPServer, "shutdown", mock_shutdown) + + # Mock server close + mock_server_close = mock.MagicMock() + monkeypatch.setattr(HTTPServer, "server_close", mock_server_close) + + # Mock the webbrowser + mock_open_new_tab = mock.MagicMock() + monkeypatch.setattr(webbrowser, "open_new_tab", mock_open_new_tab) + + # Run the app + run_command.run_app( + first_app_built, + test_mode=True, + passthrough=[], + host="localhost", + port=8080, + open_browser=True, + ) + + # The browser was *not* opened + mock_open_new_tab.assert_not_called() + + # The server was started + mock_serve_forever.assert_called_once_with() + + # The page was loaded in the playwright session + mock_page.goto.assert_called_once_with("http://127.0.0.1:8080") + + # The suite passed immediately; no timeout was required + mock_page.wait_for_timeout.assert_not_called() + + # The webserver was shutdown. + mock_shutdown.assert_called_once_with() + + # The webserver was closed. + mock_server_close.assert_called_once_with() + + +def test_run_test_mode_failure(monkeypatch, run_command, first_app_built): + """A static web app can fail a test suite.""" + # Mock server creation + mock_server_init = mock.MagicMock(spec_set=HTTPServer) + monkeypatch.setattr(HTTPServer, "__init__", mock_server_init) + + # Mock the socket name returned by the server. + socket = mock.MagicMock() + socket.getsockname.return_value = ("127.0.0.1", "8080") + LocalHTTPServer.socket = socket + + # Mock playwright + mock_playwright = mock.MagicMock() + run_command.playwright = mock_playwright + + mock_playwright_instance = mock.MagicMock() + mock_playwright.return_value = mock_playwright_instance + + mock_playwright_session = mock.MagicMock() + mock_playwright_instance.__enter__.return_value = mock_playwright_session + + mock_browser = mock.MagicMock() + mock_playwright_session.chromium.launch.return_value = mock_browser + + mock_page = mock.MagicMock() + mock_browser.new_page.return_value = mock_page + + # Inject a log lines indicating test failure into the mocked web + # console. This is done as a side effect of the `wait_for_timeout` call; we + # then inspect backwards to get the argument that was passed to the + # `page.on()` call to get the console logger. This has the effect of + # injecting content into the logger, but only *after* we've passed through + # once, + def fill_buffer(url): + console = mock_page.on.mock_calls[-1].args[1] + msg = mock.Mock() + for i in range(1, 100): + msg.text = f"Test suite is running [{i}%]" + console(msg) + + msg.text = ">>>>>>>>>> EXIT 1 <<<<<<<<<<" + console(msg) + + mock_page.wait_for_timeout.side_effect = fill_buffer + + # Mock subprocess + run_command.tools.subprocess = mock.MagicMock() + + # Mock server execution, raising a user exit. + shutdown_event = Event() + + mock_serve_forever = mock.MagicMock( + side_effect=lambda: shutdown_event.wait(timeout=2) + ) + monkeypatch.setattr(HTTPServer, "serve_forever", mock_serve_forever) + + # Mock shutdown + mock_shutdown = mock.MagicMock(side_effect=lambda: shutdown_event.set()) + monkeypatch.setattr(HTTPServer, "shutdown", mock_shutdown) + + # Mock server close + mock_server_close = mock.MagicMock() + monkeypatch.setattr(HTTPServer, "server_close", mock_server_close) + + # Mock the webbrowser + mock_open_new_tab = mock.MagicMock() + monkeypatch.setattr(webbrowser, "open_new_tab", mock_open_new_tab) + + # Run the app + with pytest.raises(BriefcaseTestSuiteFailure): + run_command.run_app( + first_app_built, + test_mode=True, + passthrough=[], + host="localhost", + port=8080, + open_browser=True, + ) + + # The browser was *not* opened + mock_open_new_tab.assert_not_called() + + # The server was started + mock_serve_forever.assert_called_once_with() + + # The page was loaded in the playwright session + mock_page.goto.assert_called_once_with("http://127.0.0.1:8080") + + # At least one call to wait for new console content was made. + mock_page.wait_for_timeout.assert_called_with(100) + + # The webserver was shutdown. + mock_shutdown.assert_called_once_with() + + # The webserver was closed. + mock_server_close.assert_called_once_with() + + +def test_run_test_mode_no_status(monkeypatch, run_command, first_app_built): + """If a static web app doesn't report a status, an error is raised.""" + # Mock server creation + mock_server_init = mock.MagicMock(spec_set=HTTPServer) + monkeypatch.setattr(HTTPServer, "__init__", mock_server_init) + + # Mock the socket name returned by the server. + socket = mock.MagicMock() + socket.getsockname.return_value = ("127.0.0.1", "8080") + LocalHTTPServer.socket = socket + + # Mock playwright + mock_playwright = mock.MagicMock() + run_command.playwright = mock_playwright + + mock_playwright_instance = mock.MagicMock() + mock_playwright.return_value = mock_playwright_instance + + mock_playwright_session = mock.MagicMock() + mock_playwright_instance.__enter__.return_value = mock_playwright_session + + mock_browser = mock.MagicMock() + mock_playwright_session.chromium.launch.return_value = mock_browser + + mock_page = mock.MagicMock() + mock_browser.new_page.return_value = mock_page + + # Inject "normal operation" log lines, but no success/failure into the + # mocked web console. + def fill_buffer(url): + console = mock_page.on.mock_calls[-1].args[1] + for i in range(1, 100): + msg = mock.Mock() + msg.text = f"Test suite is running [{i}%]" + console(msg) + + mock_page.goto.side_effect = fill_buffer + + # When we hit wait_for_timeout, raise a streaming error. + # This is the closest we can get to mocking the log filter + # raising StopStreaming. + mock_page.wait_for_timeout.side_effect = StopStreaming + + # Mock subprocess + run_command.tools.subprocess = mock.MagicMock() + + # Mock server execution, raising a user exit. + shutdown_event = Event() + + mock_serve_forever = mock.MagicMock( + side_effect=lambda: shutdown_event.wait(timeout=2) + ) + monkeypatch.setattr(HTTPServer, "serve_forever", mock_serve_forever) + + # Mock shutdown + mock_shutdown = mock.MagicMock(side_effect=lambda: shutdown_event.set()) + monkeypatch.setattr(HTTPServer, "shutdown", mock_shutdown) + + # Mock server close + mock_server_close = mock.MagicMock() + monkeypatch.setattr(HTTPServer, "server_close", mock_server_close) + + # Mock the webbrowser + mock_open_new_tab = mock.MagicMock() + monkeypatch.setattr(webbrowser, "open_new_tab", mock_open_new_tab) + # Run the app with pytest.raises( BriefcaseCommandError, - match=r"Briefcase can't run web apps in test mode.", + match=r"Test suite didn't report a result", ): run_command.run_app( first_app_built, @@ -509,3 +790,21 @@ def test_test_mode(run_command, first_app_built): port=8080, open_browser=True, ) + + # The browser was *not* opened + mock_open_new_tab.assert_not_called() + + # The server was started + mock_serve_forever.assert_called_once_with() + + # The page was loaded in the playwright session + mock_page.goto.assert_called_once_with("http://127.0.0.1:8080") + + # At least one call to wait for new console content was made. + mock_page.wait_for_timeout.assert_called_with(100) + + # The webserver was shutdown. + mock_shutdown.assert_called_once_with() + + # The webserver was closed. + mock_server_close.assert_called_once_with()