import pdb import re import sys import time import traceback import urllib from dataclasses import dataclass import py import pytest from playwright.sync_api import Error as PlaywrightError ROOT = py.path.local(__file__).dirpath("..", "..", "..") BUILD = ROOT.join("pyscriptjs", "build") @pytest.mark.usefixtures("init") class PyScriptTest: """ Base class to write PyScript integration tests, based on playwright. It provides a simple API to generate HTML files and load them in playwright. It also provides a Pythonic API on top of playwright for the most common tasks; in particular: - self.console collects all the JS console.* messages. Look at the doc of ConsoleMessageCollection for more details. - self.check_errors() checks that no JS errors have been thrown - after each test, self.check_errors() is automatically run to ensure that no JS error passes uncaught. - self.wait_for_console waits until the specified message appears in the console - self.wait_for_pyscript waits until all the PyScript tags have been evaluated - self.pyscript_run is the main entry point for pyscript tests: it creates an HTML page to run the specified snippet. """ # Pyodide always print()s this message upon initialization. Make it # available to all tests so that it's easiert to check. PY_COMPLETE = "Python initialization complete" @pytest.fixture() def init(self, request, tmpdir, logger, page): """ Fixture to automatically initialize all the tests in this class and its subclasses. The magic is done by the decorator @pytest.mark.usefixtures("init"), which tells pytest to automatically use this fixture for all the test method of this class. Using the standard pytest behavior, we can request more fixtures: tmpdir, and page; 'page' is a fixture provided by pytest-playwright. Then, we save these fixtures on the self and proceed with more initialization. The end result is that the requested fixtures are automatically made available as self.xxx in all methods. """ self.testname = request.function.__name__.replace("test_", "") self.tmpdir = tmpdir # create a symlink to BUILD inside tmpdir tmpdir.join("build").mksymlinkto(BUILD) self.tmpdir.chdir() self.logger = logger self.fake_server = "http://fake_server" self.router = SmartRouter( "fake_server", logger=logger, usepdb=request.config.option.usepdb ) self.router.install(page) self.init_page(page) # # this extra print is useful when using pytest -s, else we start printing # in the middle of the line print() # # if you use pytest --headed you can see the browser page while # playwright executes the tests, but the page is closed very quickly # as soon as the test finishes. To avoid that, we automatically start # a pdb so that we can wait as long as we want. yield if request.config.option.headed: pdb.Pdb.intro = ( "\n" "This (Pdb) was started automatically because you passed --headed:\n" "the execution of the test pauses here to give you the time to inspect\n" "the browser. When you are done, type one of the following commands:\n" " (Pdb) continue\n" " (Pdb) cont\n" " (Pdb) c\n" ) pdb.set_trace() def init_page(self, page): self.page = page # set default timeout to 60000 millliseconds from 30000 page.set_default_timeout(60000) self.console = ConsoleMessageCollection(self.logger) self._page_errors = [] page.on("console", self.console.add_message) page.on("pageerror", self._on_pageerror) def teardown_method(self): # we call check_errors on teardown: this means that if there are still # non-cleared errors, the test will fail. If you expect errors in your # page and they should not cause the test to fail, you should call # self.check_errors() in the test itself. self.check_errors() def _on_pageerror(self, error): self.logger.log("JS exception", error.stack, color="red") self._page_errors.append(error) def check_errors(self): """ Check whether JS errors were reported. If it finds a single JS error, raise JsError. If it finds multiple JS errors, raise JsMultipleErrors. Upon return, all the errors are cleared, so a subsequent call to check_errors will not raise, unless NEW JS errors have been reported in the meantime. """ exc = None if len(self._page_errors) == 1: # if there is a single error, wrap it exc = JsError(self._page_errors[0]) elif len(self._page_errors) >= 2: exc = JsMultipleErrors(self._page_errors) self._page_errors = [] if exc: raise exc def clear_errors(self): """ Clear all JS errors. """ self._page_errors = [] def writefile(self, filename, content): """ Very thin helper to write a file in the tmpdir """ f = self.tmpdir.join(filename) f.write(content) def goto(self, path): self.logger.reset() self.logger.log("page.goto", path, color="yellow") url = f"{self.fake_server}/{path}" self.page.goto(url, timeout=0) def wait_for_console(self, text, *, timeout=None, check_errors=True): """ Wait until the given message appear in the console. Note: it must be the *exact* string as printed by e.g. console.log. If you need more control on the predicate (e.g. if you want to match a substring), use self.page.expect_console_message directly. timeout is expressed in milliseconds. If it's None, it will use playwright's own default value, which is 30 seconds). If check_errors is True (the default), it also checks that no JS errors were raised during the waiting. """ pred = lambda msg: msg.text == text try: with self.page.expect_console_message(pred, timeout=timeout): pass finally: # raise JsError if there were any javascript exception. Note that # this might happen also in case of a TimeoutError. In that case, # the JsError will shadow the TimeoutError but this is correct, # because it's very likely that the console message never appeared # precisely because of the exception in JS. if check_errors: self.check_errors() def wait_for_pyscript(self, *, timeout=None, check_errors=True): """ Wait until pyscript has been fully loaded. Timeout is expressed in milliseconds. If it's None, it will use playwright's own default value, which is 30 seconds). If check_errors is True (the default), it also checks that no JS errors were raised during the waiting. """ # this is printed by runtime.ts:Runtime.initialize self.wait_for_console( "[pyscript/main] PyScript page fully initialized", timeout=timeout, check_errors=check_errors, ) # We still don't know why this wait is necessary, but without it # events aren't being triggered in the tests. self.page.wait_for_timeout(100) def pyscript_run(self, snippet, *, extra_head="", wait_for_pyscript=True): """ Main entry point for pyscript tests. snippet contains a fragment of HTML which will be put inside a full HTML document. In particular, the
automatically contains the correct {extra_head} {snippet}