From 162fd84e1971f4eaa377f21af29ea6dcac092530 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 6 May 2023 17:27:43 +0800 Subject: [PATCH 1/7] Remove redundant popen argument to log filter. --- src/briefcase/commands/run.py | 4 ---- tests/commands/run/test_LogFilter.py | 10 ---------- 2 files changed, 14 deletions(-) diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 93bb77d10..8430c1b96 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -14,14 +14,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 @@ -32,7 +30,6 @@ def __init__( 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 @@ -151,7 +148,6 @@ def _stream_app_logs( ) log_filter = LogFilter( - popen, clean_filter=clean_filter, clean_output=clean_output, exit_filter=exit_filter, 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, From 07c2116fb1e0ed727bb590158df91e381682729c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 6 May 2023 17:29:11 +0800 Subject: [PATCH 2/7] Add initial test mode for web backend. --- setup.cfg | 1 + src/briefcase/platforms/web/static.py | 122 ++++++++++++++++++++----- tests/platforms/web/static/test_run.py | 30 +++--- 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/setup.cfg b/setup.cfg index 45cc34812..ed3aed4bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -78,6 +78,7 @@ install_requires = dmgbuild >= 1.6, < 2.0; sys_platform == "darwin" GitPython >= 3.1, < 4.0 platformdirs >= 2.6, < 4.0 + playwright >= 1.33.0, < 2.0 psutil >= 5.9, < 6.0 requests >= 2.28, < 3.0 rich >= 12.6, < 14.0 diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index f4bf327ba..b54ad657a 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -4,17 +4,17 @@ 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 import tomli as tomllib import tomli_w +from playwright.sync_api import sync_playwright from briefcase.commands import ( BuildCommand, @@ -25,8 +25,15 @@ RunCommand, UpdateCommand, ) +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: @@ -245,9 +252,10 @@ 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): @@ -308,9 +316,6 @@ def run_app( :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. @@ -319,12 +324,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 @@ -332,19 +339,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, + ) + + # 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 sync_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 + 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) + 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/platforms/web/static/test_run.py b/tests/platforms/web/static/test_run.py index 04e54f1db..c4c876490 100644 --- a/tests/platforms/web/static/test_run.py +++ b/tests/platforms/web/static/test_run.py @@ -494,18 +494,18 @@ def test_log_requests_to_logger(monkeypatch): 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).""" - # Run the app - with pytest.raises( - BriefcaseCommandError, - match=r"Briefcase can't run web apps in test mode.", - ): - run_command.run_app( - first_app_built, - test_mode=True, - passthrough=[], - host="localhost", - port=8080, - open_browser=True, - ) +# def test_test_mode(run_command, first_app_built): +# """Test mode raises an error (at least for now).""" +# # Run the app +# with pytest.raises( +# BriefcaseCommandError, +# match=r"Briefcase can't run web apps in test mode.", +# ): +# run_command.run_app( +# first_app_built, +# test_mode=True, +# passthrough=[], +# host="localhost", +# port=8080, +# open_browser=True, +# ) From 882c9d8227c37ce307b403edce58cde24fcb93b9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 7 May 2023 10:32:10 +0800 Subject: [PATCH 3/7] Add note about splash screen format. --- docs/reference/platforms/web.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/platforms/web.rst b/docs/reference/platforms/web.rst index 48f58c616..05006cb45 100644 --- a/docs/reference/platforms/web.rst +++ b/docs/reference/platforms/web.rst @@ -36,7 +36,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 ========================= From e01be6ce915b97697ae0fc354d1bc34756a6b6c1 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 8 May 2023 14:34:43 +0800 Subject: [PATCH 4/7] Allow for insertion of content provided by the packaged wheels. --- src/briefcase/platforms/web/static.py | 334 +++++++++++++----- tests/platforms/web/static/conftest.py | 23 +- tests/platforms/web/static/test_build.py | 95 +++-- .../web/static/test_build__process_wheel.py | 168 +++++---- .../web/static/test_build__trim_file.py | 120 ------- 5 files changed, 445 insertions(+), 295 deletions(-) delete mode 100644 tests/platforms/web/static/test_build__trim_file.py diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index b54ad657a..c75e91e25 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -1,4 +1,5 @@ import errno +import re import subprocess import sys import webbrowser @@ -46,8 +47,11 @@ def project_path(self, app): 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" @@ -74,61 +78,239 @@ 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 _base_inserts(self, app: AppConfig, test_mode: bool): + """Construct the initial runtime inserts for the app. + + This adds: + * A bootstrap script for ``index.html`` to start the app + + :param app: The app whose base inserts we need. + :param test_mode: Boolean; Is the app running in test mode? + :returns: A dictionary containing the initial inserts + """ + # 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)', + ] + ) + + return { + "index.html": { + "bootstrap": { + "Briefcase": "\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. + + :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 accomodate HTML, + Python and CSS/JS comment conventions: + * HTML: ```` and ```` + * Python: ``#####@ insert:start @#####\n`` and ``######@ insert:end @#####\n`` + * CSS/JS: ``/*****@ insert:end @*****/`` and ``/*****@ insert:end @*****/`` - def build_app(self, app: AppConfig, **kwargs): + :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) @@ -180,51 +362,31 @@ 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 = self._base_inserts(app, test_mode=test_mode) - # 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") + + # 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 {} 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 3b92919a1..0efcc02f5 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][3] == "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: @@ -125,7 +130,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: @@ -137,31 +142,69 @@ def mock_run(*args, **kwargs): "#pyconsole {", " display: None;", "}", - "/*******************************************************************", - " ******************** Wheel contributed styles ********************/", + "/*****@ CSS:start @*****/", + "/**************************************************", + " * dependency 1.2.3", + " *************************************************/", + "/********** dependency.css **********/", + "div { margin: 20px; }", "", - "/*******************************************************", - " * dependency 1.2.3::style.css", - " *******************************************************/", "", - "div { margin: 10px; }", + "/**************************************************", + " * first_app 1.2.3", + " *************************************************/", + "/********** style.css **********/", + "span { margin: 10px; }", "", - "/*******************************************************", - " * first_app 1.2.3::style.css", - " *******************************************************/", + "/********** main.css **********/", + "span { padding: 10px; }", "", - "span { margin: 10px; }", "", - "/*******************************************************", - " * other 1.2.3::style.css", - " *******************************************************/", + "/**************************************************", + " * other 1.2.3", + " *************************************************/", + "/********** style.css **********/", + "div { padding: 30px; }", "", - "div { padding: 10px; }", + "/*****@ 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 @#####", + "##################################################", + "# Briefcase", + "##################################################", + "import runpy", + "", + "# Run First App's main module", + 'runpy.run_module("first_app", run_name="__main__", alter_sys=True)', + "#####@ 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.""" @@ -296,7 +339,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][3] == "pip": @@ -375,14 +418,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" diff --git a/tests/platforms/web/static/test_build__process_wheel.py b/tests/platforms/web/static/test_build__process_wheel.py index 36e8ef77f..7f74a9fde 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 existng 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) From 6477cd5dbb9b5a186a149da8422ec4a3722c4fb1 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 11 May 2023 09:51:17 +0800 Subject: [PATCH 5/7] Add tests for test mode on web backend. --- pyproject.toml | 1 + setup.cfg | 3 +- src/briefcase/platforms/web/static.py | 23 +- tests/platforms/web/static/test_build.py | 177 ++++++++++++ tests/platforms/web/static/test_run.py | 331 +++++++++++++++++++++-- 5 files changed, 514 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8a3bf1a23..d1489e89c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ no-cover-if-not-windows = "'win32' != os_environ.get('COVERAGE_PLATFORM', sys_pl no-cover-if-is-py310 = "python_version == '3.10' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" no-cover-if-lt-py310 = "sys_version_info < (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" no-cover-if-gte-py310 = "sys_version_info > (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-lt-py312 = "sys_version_info < (3, 12) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" [tool.isort] profile = "black" diff --git a/setup.cfg b/setup.cfg index ed3aed4bd..fb610891f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -78,7 +78,8 @@ install_requires = dmgbuild >= 1.6, < 2.0; sys_platform == "darwin" GitPython >= 3.1, < 4.0 platformdirs >= 2.6, < 4.0 - playwright >= 1.33.0, < 2.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/platforms/web/static.py b/src/briefcase/platforms/web/static.py index c75e91e25..68e132825 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -15,7 +15,12 @@ import tomli as tomllib import tomli_w -from playwright.sync_api import sync_playwright + +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, @@ -424,7 +429,13 @@ 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 @@ -434,6 +445,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( @@ -518,7 +533,7 @@ def run_app( self.logger.info("=" * 75) # Open a Playwright session - with sync_playwright() as playwright: + with self.playwright() as playwright: browser = playwright.chromium.launch(headless=not open_browser) page = browser.new_page() @@ -542,7 +557,7 @@ def run_app( while True: # Process all the lines in the accumulated log buffer, # looking for the termination condition. Finding the - # termination condition is what stops the + # termination condition is what stops the test suite. for line in buffer: for filtered in log_filter(line): self.logger.info(filtered) diff --git a/tests/platforms/web/static/test_build.py b/tests/platforms/web/static/test_build.py index 0efcc02f5..5dc3d41ee 100644 --- a/tests/platforms/web/static/test_build.py +++ b/tests/platforms/web/static/test_build.py @@ -206,6 +206,183 @@ def mock_run(*args, **kwargs): ) +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][3] == "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][3] == "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", + "-m", + "wheel", + "pack", + bundle_path / "app", + "--dest-dir", + bundle_path / "www" / "static" / "wheels", + ], + check=True, + ), + mock.call( + [ + sys.executable, + "-u", + "-m", + "pip", + "wheel", + "--wheel-dir", + bundle_path / "www" / "static" / "wheels", + "-r", + bundle_path / "requirements.txt", + ], + check=True, + ), + ] + + # 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; }", + "", + "/********** main.css **********/", + "span { 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 @#####", + "##################################################", + "# Briefcase", + "##################################################", + "import runpy", + "", + "# Run First App's main module", + 'runpy.run_module("first_app", run_name="__main__", alter_sys=True)', + "", + "# Run First App's test module", + 'runpy.run_module("tests.first_app", run_name="__main__", alter_sys=True)', + "#####@ 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.""" bundle_path = tmp_path / "base_path" / "build" / "first-app" / "web" / "static" diff --git a/tests/platforms/web/static/test_run.py b/tests/platforms/web/static/test_run.py index c4c876490..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,25 +489,322 @@ 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).""" -# # Run the app -# with pytest.raises( -# BriefcaseCommandError, -# match=r"Briefcase can't run web apps in test mode.", -# ): -# run_command.run_app( -# first_app_built, -# test_mode=True, -# passthrough=[], -# host="localhost", -# port=8080, -# open_browser=True, -# ) +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"Test suite didn't report a result", + ): + 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() From 4ad37cb430c66443b4f55102945db6c19272859c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 21 May 2023 15:33:17 +0800 Subject: [PATCH 6/7] Add changenote. --- changes/1166.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/1166.feature.rst 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 From e61a0d28e8962b40d01c293b1f7221cc5e284da4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 22 Jul 2023 17:48:06 +0200 Subject: [PATCH 7/7] WIP dependencies change. --- src/briefcase/platforms/web/static.py | 82 +++++++++++++++++++++------ 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index 53b417cdb..fd1841ffb 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -31,6 +31,7 @@ RunCommand, UpdateCommand, ) +from briefcase.commands.create import _is_local_requirement from briefcase.commands.run import LogFilter from briefcase.config import AppConfig from briefcase.console import Log @@ -46,6 +47,9 @@ 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" @@ -71,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." @@ -170,15 +221,11 @@ def _write_pyscript_toml(self, app: AppConfig): # Write the final configuration. tomli_w.dump(config, f) - def _base_inserts(self, app: AppConfig, test_mode: bool): - """Construct the initial runtime inserts for the app. - - This adds: - * A bootstrap script for ``index.html`` to start the app + 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? - :returns: A dictionary containing the initial inserts """ # Construct the bootstrap script. bootstrap = [ @@ -196,13 +243,8 @@ def _base_inserts(self, app: AppConfig, test_mode: bool): ] ) - return { - "index.html": { - "bootstrap": { - "Briefcase": "\n".join(bootstrap), - } - } - } + 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. @@ -353,13 +395,19 @@ def build_app(self, app: AppConfig, test_mode: bool = False, **kwargs): "-u", "-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), ) except subprocess.CalledProcessError as e: raise BriefcaseCommandError( @@ -369,7 +417,7 @@ def build_app(self, app: AppConfig, test_mode: bool = False, **kwargs): with self.input.wait_bar("Writing Pyscript configuration file..."): self._write_pyscript_toml(app) - inserts = self._base_inserts(app, test_mode=test_mode) + inserts = {} self.logger.info("Compile contributed content from wheels") with self.input.wait_bar("Compiling contributed content from wheels..."): @@ -386,6 +434,8 @@ def build_app(self, app: AppConfig, test_mode: bool = False, **kwargs): # 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..."):