import dataclasses import os 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_js_errors() checks that no JS errors have been thrown - after each test, self.check_js_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 if request.config.option.no_fake_server: # use a real HTTP server. Note that as soon as we request the # fixture, the server automatically starts in its own thread. self.http_server = request.getfixturevalue("http_server") self.router = None self.is_fake_server = False else: # use the internal playwright routing self.http_server = "http://fake_server" self.router = SmartRouter( "fake_server", cache=request.config.cache, logger=logger, usepdb=request.config.option.usepdb, ) self.router.install(page) self.is_fake_server = True # 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._js_errors = [] page.on("console", self._on_console) page.on("pageerror", self._on_pageerror) def teardown_method(self): # we call check_js_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_js_errors() in the test itself. self.check_js_errors() def _on_console(self, msg): self.console.add_message(msg.type, msg.text) def _on_pageerror(self, error): self.console.add_message("js_error", error.stack) self._js_errors.append(error) def check_js_errors(self, *expected_messages): """ Check whether JS errors were reported. expected_messages is a list of strings of errors that you expect they were raised in the page. They are checked using a simple 'in' check, equivalent to this: if expected_message in actual_error_message: ... If an error was expected but not found, it raises DidNotRaiseJsError(). If there are MORE errors other than the expected ones, it raises JsErrors. Upon return, all the errors are cleared, so a subsequent call to check_js_errors will not raise, unless NEW JS errors have been reported in the meantime. """ expected_messages = list(expected_messages) js_errors = self._js_errors[:] for i, msg in enumerate(expected_messages): for j, error in enumerate(js_errors): if msg is not None and error is not None and msg in error.message: # we matched one expected message with an error, remove both expected_messages[i] = None js_errors[j] = None # if everything is find, now expected_messages and js_errors contains # only Nones. If they contain non-None elements, it means that we # either have messages which are expected-but-not-found or errors # which are found-but-not-expected. expected_messages = [msg for msg in expected_messages if msg is not None] js_errors = [err for err in js_errors if err is not None] self.clear_js_errors() if expected_messages: # expected-but-not-found raise JsErrorsDidNotRaise(expected_messages, js_errors) if js_errors: # found-but-not-expected raise JsErrors(js_errors) def clear_js_errors(self): """ Clear all JS errors. """ self._js_errors = [] def writefile(self, filename, content): """ Very thin helper to write a file in the tmpdir """ f = self.tmpdir.join(filename) f.dirpath().ensure(dir=True) f.write(content) def goto(self, path): self.logger.reset() self.logger.log("page.goto", path, color="yellow") url = f"{self.http_server}/{path}" self.page.goto(url, timeout=0) def wait_for_console(self, text, *, timeout=None, check_js_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_js_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_js_errors: self.check_js_errors() def wait_for_pyscript(self, *, timeout=None, check_js_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_js_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_js_errors=check_js_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}