diff --git a/positron/src/positron/django_templates/manage.py.tmpl b/positron/src/positron/django_templates/manage.py.tmpl index 9a95520e2d..137db483d7 100644 --- a/positron/src/positron/django_templates/manage.py.tmpl +++ b/positron/src/positron/django_templates/manage.py.tmpl @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/travertino/pyproject.toml b/travertino/pyproject.toml index 96b4369198..92f82d123a 100644 --- a/travertino/pyproject.toml +++ b/travertino/pyproject.toml @@ -41,6 +41,10 @@ classifiers = [ "Topic :: Software Development :: User Interfaces", ] +dependencies = [ + "packaging", +] + [project.urls] Homepage = "https://beeware.org/travertino" Funding = "https://beeware.org/contributing/membership/" diff --git a/web-testbed/README.md b/web-testbed/README.md new file mode 100644 index 0000000000..9bdf2455f0 --- /dev/null +++ b/web-testbed/README.md @@ -0,0 +1,15 @@ +This repository is dedicated to development, testing, and proof-of-concept work related to issue [3545](https://github.com/beeware/toga/issues/3545), which focuses on implementing testing for the web platform. + +## How We Run this Test Suite +1. Open this directory. +2. Create a Python 3.12 virtual environment and install test requirements: + - `python3.12 -m venv venv` + - `source venv/bin/activate` + - `pip install -U pip` + - `pip install --group test` + - `playwright install chromium` +3. Run your Toga app as a web app. + - `briefcase run web` +4. In a separate terminal, run the test suite: + - `source venv/bin/activate` + - `pytest tests` diff --git a/web-testbed/pyproject.toml b/web-testbed/pyproject.toml new file mode 100644 index 0000000000..9d12f312ea --- /dev/null +++ b/web-testbed/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "testbed" +version = "0.0.1" + +[dependency-groups] +test = [ + "briefcase", + "playwright == 1.51.0", + # "pytest==8.4.1", + "pytest==8.3.5", + # "pytest-asyncio==1.1.0", + "pytest-asyncio==0.26.0", + "pytest-playwright==0.7.0", +] + +[tool.briefcase] +project_name = "Toga Web Testbed" +bundle = "org.beeware.toga" +url = "https://beeware.org" +license = "BSD-3-Clause" +license-files = [ + "LICENSE", +] +author = "Tiberius Yak" +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.testbed] +formal_name = "Toga Testbed" +description = "A testbed for Toga visual tests" +icon = "icons/testbed" +sources = [ + "src/testbed", +] +test_sources = [ + "tests", +] +requires = [ + "../travertino", + "../core", +] + +[tool.briefcase.app.testbed.web] +requires = [ + "../web" +] +style_framework = "Shoelace v2.3" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/web-testbed/run_tests.py b/web-testbed/run_tests.py new file mode 100644 index 0000000000..55dd648839 --- /dev/null +++ b/web-testbed/run_tests.py @@ -0,0 +1,69 @@ +import os +import signal +import subprocess +import sys +import time +from shutil import which + +SERVER_CMD = ["briefcase", "run", "web", "--no-browser"] +TEST_CMD = ["pytest", "tests"] +STARTUP_WAIT_SECS = float(os.getenv("SERVER_STARTUP_SECS", "5.0")) + +IS_WINDOWS = os.name == "nt" +CREATE_NEW_PROCESS_GROUP = 0x00000200 if IS_WINDOWS else 0 + + +def start_server(): + if which(SERVER_CMD[0]) is None: + print(f"Error: '{SERVER_CMD[0]}' not found on PATH.", file=sys.stderr) + sys.exit(127) + kwargs = {} + if IS_WINDOWS: + kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.STDOUT + return subprocess.Popen(SERVER_CMD, **kwargs) + + +def stop_server(proc, timeout=10): + if proc.poll() is not None: + return + try: + if IS_WINDOWS: + # Try to be gentle first + try: + proc.send_signal(signal.CTRL_BREAK_EVENT) + except Exception: + proc.terminate() + else: + proc.send_signal(signal.SIGINT) + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + proc.terminate() + proc.wait(timeout=3) + except Exception: + proc.kill() + + +def main(): + print("> Starting web server:", " ".join(SERVER_CMD)) + server = start_server() + + try: + time.sleep(STARTUP_WAIT_SECS) + print("> Running tests:", " ".join(TEST_CMD)) + result = subprocess.run(TEST_CMD) + exit_code = result.returncode + except KeyboardInterrupt: + print("\n> Interrupted by user.", file=sys.stderr) + exit_code = 130 + finally: + print("> Shutting down web server…") + stop_server(server) + + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/web-testbed/src/testbed/__init__.py b/web-testbed/src/testbed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-testbed/src/testbed/__main__.py b/web-testbed/src/testbed/__main__.py new file mode 100644 index 0000000000..7c4c4d4546 --- /dev/null +++ b/web-testbed/src/testbed/__main__.py @@ -0,0 +1,4 @@ +from testbed.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/web-testbed/src/testbed/app.py b/web-testbed/src/testbed/app.py new file mode 100644 index 0000000000..bda9750961 --- /dev/null +++ b/web-testbed/src/testbed/app.py @@ -0,0 +1,22 @@ +import toga +from toga.style import Pack +from toga.style.pack import COLUMN + +from .web_test_harness import WebTestHarness + + +class HelloWorld(toga.App): + def startup(self): + main_box = toga.Box(style=Pack(direction=COLUMN)) + self.label = toga.Label(id="myLabel", text="Test App - Toga Web Testing") + + self.web_test = WebTestHarness(self) + + main_box.add(self.label) + self.main_window = toga.MainWindow(title=self.formal_name) + self.main_window.content = main_box + self.main_window.show() + + +def main(): + return HelloWorld() diff --git a/web-testbed/src/testbed/web_test_harness.py b/web-testbed/src/testbed/web_test_harness.py new file mode 100644 index 0000000000..78ed2accf1 --- /dev/null +++ b/web-testbed/src/testbed/web_test_harness.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import datetime as _dt +import os +import textwrap +import types +from unittest.mock import Mock + +import toga + +try: + import js +except ModuleNotFoundError: + js = None + +try: + from pyodide.ffi import create_proxy, to_js +except Exception: + create_proxy = None + to_js = None + + +def _truthy(v) -> bool: + return str(v).strip().lower() in {"1", "true", "yes", "on"} + + +def web_testing_enabled() -> bool: + if _truthy(os.getenv("TOGA_WEB_TESTING")): + return True + + if js is not None: + try: + if _truthy(getattr(js.window, "TOGA_WEB_TESTING", "")): + return True + qs = str(getattr(js.window, "location", None).search or "") + if "toga_web_testing" in qs.lower(): + return True + except Exception: + pass + return False + + +class WebTestHarness: + def __init__(self, app, *, expose_name: str = "test_cmd"): + self.app = app + self.my_objs = {} + self.app.my_objs = self.my_objs + self._capabilities = {} + self.my_objs["__caps__"] = self._capabilities + + self.my_objs["__app__"] = self.app + + self._js_available = ( + js is not None and create_proxy is not None and to_js is not None + ) + if self._js_available and web_testing_enabled(): + js.window.test_cmd = create_proxy(self.cmd_test) + js.window.test_cmd_rpc = create_proxy(self.cmd_test_rpc) + + def cmd_test(self, code): + try: + env = globals().copy() + env.update(locals()) + + env["self"] = self.app + env["toga"] = toga + env["my_objs"] = self.my_objs + env["Mock"] = Mock + + exec(code, env, env) + result = env.get("result") + envelope = self._serialise_payload(result) + return to_js(envelope, dict_converter=js.Object.fromEntries) + except Exception as e: + return to_js( + {"type": "error", "value": str(e)}, dict_converter=js.Object.fromEntries + ) + + def _serialise_payload(self, x): + # primitives + if x is None: + return {"type": "none", "value": None} + if isinstance(x, bool): + return {"type": "bool", "value": x} + if isinstance(x, int): + return {"type": "int", "value": x} + if isinstance(x, float): + return {"type": "float", "value": x} + if isinstance(x, str): + return {"type": "str", "value": x} + + # containers + if isinstance(x, list): + return {"type": "list", "items": [self._serialise_payload(i) for i in x]} + if isinstance(x, tuple): + return {"type": "tuple", "items": [self._serialise_payload(i) for i in x]} + if isinstance(x, dict): + items = [] + for k, v in x.items(): + if k is None: + key_env = {"type": "none", "value": None} + elif isinstance(k, bool): + key_env = {"type": "bool", "value": k} + elif isinstance(k, int): + key_env = {"type": "int", "value": k} + elif isinstance(k, float): + key_env = {"type": "float", "value": k} + elif isinstance(k, str): + key_env = {"type": "str", "value": k} + else: + key_env = {"type": "str", "value": str(k)} + items.append([key_env, self._serialise_payload(v)]) + return {"type": "dict", "items": items} + if isinstance(x, _dt.time): + return {"type": "time", "value": x.strftime("%H:%M:%S")} + if isinstance(x, _dt.date) and not isinstance(x, _dt.datetime): + return {"type": "date", "value": x.strftime("%m/%d/%Y")} + + # references by id + obj_id = self._key_for(x) + is_callable = callable(x) or isinstance( + x, (types.FunctionType, types.MethodType) + ) + return {"type": "callable" if is_callable else "object", "id": obj_id} + + def _key_for(self, x): + for k, v in self.my_objs.items(): + if v is x: + return k + # If not registered, register it + k = str(id(x)) + self.my_objs[k] = x + return k + + def _deserialise(self, env): + if env is None: + return None + if not isinstance(env, dict): + return env + + t = env.get("type") + if t in (None, "none"): + return None + if t == "bool": + return bool(env["value"]) + if t == "int": + return int(env["value"]) + if t == "float": + return float(env["value"]) + if t == "str": + return str(env["value"]) + if t == "list": + return [self._deserialise(i) for i in env["items"]] + if t == "tuple": + return tuple(self._deserialise(i) for i in env["items"]) + if t == "dict": + out = {} + for k_env, v_env in env["items"]: + k = self._deserialise(k_env) + v = self._deserialise(v_env) + out[k] = v + return out + if t == "time": + s = env["value"] + h, m, *rest = s.split(":") + sec = int(rest[0]) if rest else 0 + return _dt.time(int(h), int(m), sec) + if t == "date": + s = env["value"] + try: + return _dt.datetime.strptime(s, "%m/%d/%Y").date() + except ValueError: + return _dt.date.fromisoformat(s) + # reconstruct functions from source + if t == "callable_source": + try: + scope = {} + exec(textwrap.dedent(env["source"]), scope, scope) + fn = scope.get(env["name"]) + except Exception as e: + raise ValueError( + f"Failed to exec callable source for {env.get('name')!r}" + ) from e + + if not callable(fn): + raise ValueError( + f"Callable {env.get('name')!r} not found or not callable after exec" + ) + return fn + if t in ("ref", "object"): + ref = env.get("ref") + if ref is None: + ref = env.get("id") + return self.my_objs[str(ref)] + return env + + def cmd_test_rpc(self, msg): + m = msg.to_py() if hasattr(msg, "to_py") else msg + + op = m["op"] + + if op == "getattr": + obj = self.my_objs[str(m["obj"])] + value = getattr(obj, m["name"]) + return to_js( + self._serialise_payload(value), dict_converter=js.Object.fromEntries + ) + + if op == "setattr": + obj = self.my_objs[str(m["obj"])] + setattr(obj, m["name"], self._deserialise(m["value"])) + return to_js( + self._serialise_payload(None), dict_converter=js.Object.fromEntries + ) + + if op == "delattr": + obj = self.my_objs[str(m["obj"])] + delattr(obj, m["name"]) + return to_js( + self._serialise_payload(None), dict_converter=js.Object.fromEntries + ) + + if op == "call": + fn = self.my_objs[str(m["fn"])] + args = [self._deserialise(a) for a in m.get("args", [])] + kwargs = {k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()} + out = fn(*args, **kwargs) + return to_js( + self._serialise_payload(out), dict_converter=js.Object.fromEntries + ) + + if op == "hostcall": + fn = self._capabilities.get(m["name"]) + if not fn: + return to_js( + {"type": "error", "value": f"Unknown capability: {m['name']}"}, + dict_converter=js.Object.fromEntries, + ) + try: + out = fn( + *[self._deserialise(a) for a in m.get("args", [])], + **{k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()}, + ) + env = self._serialise_payload(out) + except Exception as e: + env = {"type": "error", "value": f"{type(e).__name__}: {e}"} + return to_js(env, dict_converter=js.Object.fromEntries) + + # Potential use for future, instead of '_create' + if op == "new": + ctor = m["ctor"] + args = [self._deserialise(a) for a in m.get("args", [])] + kwargs = {k: self._deserialise(v) for k, v in m.get("kwargs", {}).items()} + module_name, _, name = ctor.rpartition(".") + mod = __import__(module_name, fromlist=[name]) if module_name else globals() + cls = getattr(mod, name) if module_name else globals()[name] + obj = cls(*args, **kwargs) + key = self._key_for(obj) + return to_js( + self._serialise_payload(key), dict_converter=js.Object.fromEntries + ) + + raise ValueError(f"Unknown op {op!r}") diff --git a/web-testbed/tests/__init__.py b/web-testbed/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-testbed/tests/assertions.py b/web-testbed/tests/assertions.py new file mode 100644 index 0000000000..23c5e8d754 --- /dev/null +++ b/web-testbed/tests/assertions.py @@ -0,0 +1,64 @@ +from pytest import approx + +TRANSPARENT = "transparent" + + +def assert_background_color(actual, expected): + # For platforms where alpha blending is manually implemented, the + # probe.background_color property returns a tuple consisting of: + # - The widget's background color + # - The widget's parent's background color + # - The widget's original alpha value - Required for deblending + if isinstance(actual, tuple): + actual_widget_bg, actual_parent_bg, actual_widget_bg_alpha = actual + if actual_widget_bg_alpha == 0: + # Since a color having an alpha value of 0 cannot be deblended. + # So, the deblended widget color would be equal to the parent color. + deblended_actual_widget_bg = actual_parent_bg + else: + deblended_actual_widget_bg = actual_widget_bg.unblend_over( + actual_parent_bg, actual_widget_bg_alpha + ) + if isinstance(expected, tuple): + expected_widget_bg, expected_parent_bg, expected_widget_bg_alpha = expected + if expected_widget_bg_alpha == 0: + # Since a color having an alpha value of 0 cannot be deblended. + # So, the deblended widget color would be equal to the parent color. + deblended_expected_widget_bg = expected_parent_bg + else: + deblended_expected_widget_bg = expected_widget_bg.unblend_over( + expected_parent_bg, expected_widget_bg_alpha + ) + assert_color(deblended_actual_widget_bg, deblended_expected_widget_bg) + # For comparison when expected is a single value object + else: + if (expected == TRANSPARENT) or ( + expected.a == 0 + # Since a color having an alpha value of 0 cannot be deblended to + # get the exact original color, as deblending in such cases would + # lead to a division by zero error. So, just check that widget and + # parent have the same color. + ): + assert_color(actual_widget_bg, actual_parent_bg) + elif expected.a != 1: + assert_color(deblended_actual_widget_bg, expected) + else: + assert_color(actual_widget_bg, expected) + # For other platforms + else: + assert_color(actual, expected) + + +def assert_color(actual, expected): + if expected in {None, TRANSPARENT}: + assert expected == actual + else: + if actual in {None, TRANSPARENT}: + assert expected == actual + else: + assert (actual.r, actual.g, actual.b, actual.a) == ( + expected.r, + expected.g, + expected.b, + approx(expected.a, abs=(1 / 255)), + ) diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py new file mode 100644 index 0000000000..b7dfd43de8 --- /dev/null +++ b/web-testbed/tests/conftest.py @@ -0,0 +1,16 @@ +# from pytest import fixture, register_assert_rewrite, skip +import pytest + +import toga + +pytest_plugins = ["tests.tests_backend.web_test_patch"] + + +@pytest.fixture(scope="session") +def app(): + return toga.App.app() + + +@pytest.fixture(scope="session") +def main_window(app): + return app.main_window diff --git a/web-testbed/tests/data.py b/web-testbed/tests/data.py new file mode 100644 index 0000000000..fa7fcce382 --- /dev/null +++ b/web-testbed/tests/data.py @@ -0,0 +1,22 @@ +# A test object that can be used as data +class MyObject: + def __str__(self): + return "My Test Object" + + +# The text examples must both increase and decrease in size between examples to +# ensure that reducing the size of a label doesn't prevent future labels from +# increasing in size. +TEXTS = [ + "example", + "", + "a", + " ", + "ab", + "abc", + "hello world", + "hello\nworld", + "你好, wørłd!", + 1234, + MyObject(), +] diff --git a/web-testbed/tests/tests_backend/__init__.py b/web-testbed/tests/tests_backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py new file mode 100644 index 0000000000..0b5b57c05d --- /dev/null +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -0,0 +1,71 @@ +import asyncio +import threading + +from playwright.async_api import async_playwright + + +class BackgroundPage: + def __init__(self): + self._init = True + self._ready = threading.Event() + self._loop = None + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + self._ready.wait() + + def eval_js(self, js, *args): + fut = asyncio.run_coroutine_threadsafe(self._eval(js, *args), self._loop) + return fut.result() + + async def eval_js_async(self, js, *args): + fut = asyncio.run_coroutine_threadsafe(self._eval(js, *args), self._loop) + return await asyncio.wait_for(asyncio.wrap_future(fut)) + + def _run(self): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.create_task(self._bootstrap()) + self._loop.run_forever() + self._loop.close() + + async def _bootstrap(self): + self._play = await async_playwright().start() + self._browser = await self._play.chromium.launch(headless=True) + self._context = await self._browser.new_context() + await self._context.add_init_script("window.TOGA_WEB_TESTING = true;") + + self._page = await self._context.new_page() + + await self._page.goto( + "http://localhost:8080", wait_until="load", timeout=30_000 + ) + + await self._page.wait_for_function( + "() => typeof window.test_cmd === 'function'" + ) + await self._page.wait_for_function( + "() => typeof window.test_cmd_rpc === 'function'" + ) + + self._alock = asyncio.Lock() + self._ready.set() + + async def _eval(self, js, *args): + async with self._alock: + return await self._page.evaluate(js, *args) + + def run_coro(self, coro_fn, *args, **kwargs): + async def _runner(): + async with self._alock: + return await coro_fn(self._page, *args, **kwargs) + + fut = asyncio.run_coroutine_threadsafe(_runner(), self._loop) + return fut.result() + + async def run_coro_async(self, coro_fn, *args, **kwargs): + async def _runner(): + async with self._alock: + return await coro_fn(self._page, *args, **kwargs) + + fut = asyncio.run_coroutine_threadsafe(_runner(), self._loop) + return await asyncio.wait_for(asyncio.wrap_future(fut)) diff --git a/web-testbed/tests/tests_backend/probe.py b/web-testbed/tests/tests_backend/probe.py new file mode 100644 index 0000000000..9b177b0d15 --- /dev/null +++ b/web-testbed/tests/tests_backend/probe.py @@ -0,0 +1 @@ +# BaseProbe, same with SimpleProbe, maybe don't implement until later. diff --git a/web-testbed/tests/tests_backend/proxies/__init__.py b/web-testbed/tests/tests_backend/proxies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py new file mode 100644 index 0000000000..84efd7b579 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -0,0 +1,331 @@ +import datetime as _dt +import inspect + + +class ProxyProtocolError(RuntimeError): + # Raised when the remote bridge returns an invalid or unexpected payload. + pass + + +class BaseProxy: + # Remote pure expression proxy + # Attribute reads auto-realise primitives/containers, everything else stays proxied. + + _storage_expr = "self.my_objs" + + page_provider = staticmethod(lambda: None) + + # cache: js_ref to proxy instance + _instances = {} + + # per-class default local names + _local_whitelist = frozenset() + + def __init__(self, js_ref: str): + self._js_ref = js_ref + self._local_attrs = {} + self._local_names = set() + BaseProxy._instances[js_ref] = self + + @property + def js_ref(self) -> str: + return self._js_ref + + @classmethod + def _page(cls): + return cls.page_provider() + + # Core methods + + def __getattr__(self, name: str): + if self._is_declared_local(name): + local = self._local_attrs + if name in local: + return local[name] + return self._rpc("getattr", obj=self._ref(), name=name) + + def __setattr__(self, name: str, value): + if name.startswith("_"): + return super().__setattr__(name, value) + + # respect data descriptors (e.g., @property setter) + cls_attr = getattr(type(self), name, None) + if hasattr(cls_attr, "__set__"): + return object.__setattr__(self, name, value) + + # keep local policy intact + if self._is_declared_local(name): + self._local_attrs[name] = value + return + + # RPC setattr + try: + env = self._serialise_for_rpc(value, self._storage_expr) + except Exception: + # if something truly can't be serialized, keep it local. + self._local_attrs[name] = value + return + self._rpc("setattr", obj=self._ref(), name=name, value=env) + self._local_attrs.clear() + + def __delattr__(self, name: str): + if name.startswith("_"): + if hasattr(self, name): + return super().__delattr__(name) + raise AttributeError(name) + + local = self._local_attrs + if name in local: + del local[name] + return + + self._rpc("delattr", obj=self._ref(), name=name) + self._local_attrs.clear() + + def __call__(self, *args, **kwargs): + args_env = [self._serialise_for_rpc(a, self._storage_expr) for a in args] + kwargs_env = { + k: self._serialise_for_rpc(v, self._storage_expr) for k, v in kwargs.items() + } + return self._rpc("call", fn=self._ref(), args=args_env, kwargs=kwargs_env) + + # Resolve/guard + def resolve(self): + # Evaluate this expression remotely and return python value + return self._eval_and_return(self.js_ref) + + def __str__(self): + v = self.resolve() + if isinstance(v, str): + return v + raise TypeError("Resolved value is not a str; cannot coerce proxy to str.") + + def __int__(self): + v = self.resolve() + if isinstance(v, int): + return v + raise TypeError("Resolved value is not an int; cannot coerce proxy to int.") + + def __float__(self): + v = self.resolve() + if isinstance(v, float): + return v + raise TypeError("Resolved value is not a float; cannot coerce proxy to float.") + + def __bool__(self): + v = self.resolve() + if isinstance(v, (str, int, float, bool)) or v is None: + return bool(v) + if isinstance(v, (list, tuple, dict)): + return bool(v) + if isinstance(v, BaseProxy): + raise TypeError( + "Truth value of a proxied remote object is ambiguous; " + "resolve a primitive or compare explicitly." + ) + raise TypeError( + "Truth value of a non-primitive remote value is ambiguous; " + "resolve a primitive or compare explicitly." + ) + + # Remote evaluation + def _eval_and_return(self, expr_src: str): + page = self._page() + payload = page.eval_js( + "(code) => window.test_cmd(code)", f"result = {expr_src}" + ) + return self._deserialise_payload(payload) + + def _deserialise_payload(self, payload): + # De-serialise strict typed envelopes: + # - none/bool/int/float/str + # - list/tuple/dict (recursive) + # - object/callable -> proxy reference (my_objs[id]) + if not isinstance(payload, dict) or "type" not in payload: + raise ProxyProtocolError(f"Invalid payload from remote: {payload!r}") + + t = payload["type"] + + # primitives + if t == "none": + return None + if t == "bool": + return bool(payload.get("value")) + if t == "int": + return int(payload.get("value")) + if t == "float": + return float(payload.get("value")) + if t == "str": + return str(payload.get("value")) + + # containers + if t == "list": + return [ + self._deserialise_payload(item) for item in payload.get("items", []) + ] + if t == "tuple": + return tuple( + self._deserialise_payload(item) for item in payload.get("items", []) + ) + if t == "dict": + out = {} + for k_env, v_env in payload.get("items", []): + k = self._deserialise_payload(k_env) + v = self._deserialise_payload(v_env) + out[k] = v + return out + + if t == "time": + s = payload.get("value", "") + parts = [int(p) for p in s.split(":")] + if len(parts) == 2: + h, m = parts + sec = 0 + else: + h, m, sec = (parts + [0, 0, 0])[:3] + return _dt.time(h, m, sec) + + if t == "date": + s = payload.get("value", "") + try: + return _dt.datetime.strptime(s, "%m/%d/%Y").date() + except ValueError: + return _dt.date.fromisoformat(s) + + # references + if t in ("object", "callable"): + obj_id = payload["id"] + js_ref = f"{self._storage_expr}[{repr(obj_id)}]" + existing = BaseProxy._instances.get(js_ref) + if existing is not None: + return existing + p = BaseProxy(js_ref) + # cache the parsed id to avoid re-parsing js_ref later + p.__dict__["_ref_cache"] = str(obj_id) + return p + + if t == "error": + raise ProxyProtocolError(payload.get("value")) + + raise ProxyProtocolError(f"Unknown payload type: {t!r}") + + # local policy - keep Python-only stuff local + # private names, explicitly declared names, and any value containing Python + # callables stay in _local_attrs + # only primitives/containers and remote proxies are forwarded. + def _is_declared_local(self, name: str) -> bool: + return ( + name in object.__getattribute__(self, "_local_names") + or name in type(self)._local_whitelist + ) + + @staticmethod + def _extract_ref_from_expr(expr: str, storage_expr: str = "self.my_objs") -> str: + prefix = f"{storage_expr}[" + if expr.startswith(prefix) and expr.endswith("]"): + inner = expr[len(prefix) : -1].strip() + if (inner.startswith("'") and inner.endswith("'")) or ( + inner.startswith('"') and inner.endswith('"') + ): + inner = inner[1:-1] + return inner + return expr # fallback + + def _serialise_for_rpc(self, v, storage_expr="self.my_objs"): + # proxies first (no getattr!) + if isinstance(v, BaseProxy): + return {"type": "ref", "id": v._ref()} + if hasattr(v, "js_ref"): # duck-typed proxy + return { + "type": "ref", + "id": self._extract_ref_from_expr(v.js_ref, storage_expr), + } + # primitives + if v is None: + return {"type": "none", "value": None} + if isinstance(v, bool): + return {"type": "bool", "value": v} + if isinstance(v, int): + return {"type": "int", "value": v} + if isinstance(v, float): + return {"type": "float", "value": v} + if isinstance(v, str): + return {"type": "str", "value": v} + # containers + if isinstance(v, list): + return { + "type": "list", + "items": [self._serialise_for_rpc(i, storage_expr) for i in v], + } + if isinstance(v, tuple): + return { + "type": "tuple", + "items": [self._serialise_for_rpc(i, storage_expr) for i in v], + } + if isinstance(v, dict): + items = [] + for k, val in v.items(): + if k is None: + k_env = {"type": "none", "value": None} + elif isinstance(k, bool): + k_env = {"type": "bool", "value": k} + elif isinstance(k, int): + k_env = {"type": "int", "value": k} + elif isinstance(k, float): + k_env = {"type": "float", "value": k} + elif isinstance(k, str): + k_env = {"type": "str", "value": k} + else: + k_env = {"type": "str", "value": str(k)} + items.append([k_env, self._serialise_for_rpc(val, storage_expr)]) + return {"type": "dict", "items": items} + + if isinstance(v, _dt.time): + # use “HH:MM:SS” + return {"type": "time", "value": v.strftime("%H:%M:%S")} + + if isinstance(v, _dt.date) and not isinstance(v, _dt.datetime): + # use “YYYY/MM/DD” + return {"type": "date", "value": v.strftime("%m/%d/%Y")} + + if callable(v): + src = inspect.getsource(v) + name = getattr(v, "__name__", None) or "anonymous" + return { + "type": "callable_source", + "name": name, + "source": src, + } + + # final fallback: encoding unknowns as text + return {"type": "str", "value": str(v)} + + def _ref(self) -> str: + r = self.__dict__.get("_ref_cache") + if r is None: + r = self._extract_ref_from_expr(self.js_ref, self._storage_expr) + self.__dict__["_ref_cache"] = r + return r + + def _rpc(self, op, **kwargs): + page = self._page() + payload = page.eval_js( + "(msg) => window.test_cmd_rpc(msg)", {"op": op, **kwargs} + ) + return self._deserialise_payload(payload) + + @classmethod + def call_host(cls, name: str, *args, **kwargs): + temp = cls("self.my_objs['__app__']") + + args_env = [temp._serialise_for_rpc(a, temp._storage_expr) for a in args] + kwargs_env = { + k: temp._serialise_for_rpc(v, temp._storage_expr) for k, v in kwargs.items() + } + + page = cls._page() + payload = page.eval_js( + "(m) => window.test_cmd_rpc(m)", + {"op": "hostcall", "name": name, "args": args_env, "kwargs": kwargs_env}, + ) + return temp._deserialise_payload(payload) diff --git a/web-testbed/tests/tests_backend/proxies/object_proxies.py b/web-testbed/tests/tests_backend/proxies/object_proxies.py new file mode 100644 index 0000000000..6a67840813 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -0,0 +1,74 @@ +from .base_proxy import BaseProxy +from .object_proxy import ObjectProxy + + +class AppProxy(BaseProxy): + def __init__(self): + super().__init__("self.my_objs['__app__']") + # super().__init__("self") + + +AppProxy.__name__ = AppProxy.__qualname__ = "App" + + +class BoxProxy(ObjectProxy): + _ctor_expr = "toga.Box" + + +BoxProxy.__name__ = BoxProxy.__qualname__ = "Box" + + +class ButtonProxy(ObjectProxy): + _ctor_expr = "toga.Button" + + +ButtonProxy.__name__ = ButtonProxy.__qualname__ = "Button" + + +class MockProxy(ObjectProxy): + _ctor_expr = "Mock" + + +MockProxy.__name__ = MockProxy.__qualname__ = "Mock" + + +class LabelProxy(ObjectProxy): + _ctor_expr = "toga.Label" + + +LabelProxy.__name__ = LabelProxy.__qualname__ = "Label" + + +class SwitchProxy(ObjectProxy): + _ctor_expr = "toga.Switch" + + +SwitchProxy.__name__ = SwitchProxy.__qualname__ = "Switch" + + +class TextInputProxy(ObjectProxy): + _ctor_expr = "toga.TextInput" + + +TextInputProxy.__name__ = TextInputProxy.__qualname__ = "TextInput" + + +class PasswordInputProxy(ObjectProxy): + _ctor_expr = "toga.PasswordInput" + + +PasswordInputProxy.__name__ = PasswordInputProxy.__qualname__ = "PasswordInput" + + +class TimeInputProxy(ObjectProxy): + _ctor_expr = "toga.TimeInput" + + +TimeInputProxy.__name__ = TimeInputProxy.__qualname__ = "TimeInput" + + +class DateInputProxy(ObjectProxy): + _ctor_expr = "toga.DateInput" + + +DateInputProxy.__name__ = DateInputProxy.__qualname__ = "DateInput" diff --git a/web-testbed/tests/tests_backend/proxies/object_proxy.py b/web-testbed/tests/tests_backend/proxies/object_proxy.py new file mode 100644 index 0000000000..512381593e --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/object_proxy.py @@ -0,0 +1,31 @@ +from .base_proxy import BaseProxy +from .encoding import encode_value + + +# Since widgets and non-widget objects use this method to be remotely created, +# we now just use 'my_objs' for everything, also makes it simpler than with multiple. +# Also previously had trouble with 'self.widgets'. +class ObjectProxy(BaseProxy): + def __init__(self, *args, **kwargs): + key = self._create(self._ctor_expr, *args, **kwargs) + super().__init__(f"self.my_objs[{repr(key)}]") + + @classmethod + def _create(cls, ctor_expr: str, *args, **kwargs) -> str: + call_args = ", ".join( + [encode_value(a) for a in args] + + [f"{k}={encode_value(v)}" for k, v in kwargs.items()] + ) + code = ( + f"new_obj = {ctor_expr}({call_args})\n" + "key = str(id(new_obj))\n" + "self.my_objs[key] = new_obj\n" + "result = key" + ) + page = cls._page() + payload = page.eval_js("(code) => window.test_cmd(code)", code) + + if not (isinstance(payload, dict) and payload.get("type") == "str"): + raise RuntimeError(f"Unexpected payload creating widget: {payload!r}") + + return payload["value"] diff --git a/web-testbed/tests/tests_backend/web_test_patch.py b/web-testbed/tests/tests_backend/web_test_patch.py new file mode 100644 index 0000000000..e692b772ec --- /dev/null +++ b/web-testbed/tests/tests_backend/web_test_patch.py @@ -0,0 +1,75 @@ +import importlib +import sys +import types + +import pytest + +from .playwright_page import BackgroundPage +from .proxies.base_proxy import BaseProxy +from .proxies.object_proxies import ( + AppProxy, + BoxProxy, + ButtonProxy, + DateInputProxy, + LabelProxy, + MockProxy, + PasswordInputProxy, + SwitchProxy, + TextInputProxy, + TimeInputProxy, +) +from .widgets.base import SimpleProbe + +# Playwright Page injection + + +@pytest.fixture(scope="session") +def page(): + p = BackgroundPage() + return p + + +@pytest.fixture(scope="session", autouse=True) +def _wire_page(page): + BaseProxy.page_provider = staticmethod(lambda: page) + SimpleProbe.page_provider = staticmethod(lambda: page) + + +# Shims + +SHIMS = [ + ("toga", "App.app", AppProxy), + ("toga", "Button", ButtonProxy), + ("toga", "Box", BoxProxy), + ("toga", "Label", LabelProxy), + ("toga", "Switch", SwitchProxy), + ("toga", "TextInput", TextInputProxy), + ("toga", "PasswordInput", PasswordInputProxy), + ("toga", "TimeInput", TimeInputProxy), + ("toga", "DateInput", DateInputProxy), + ("unittest.mock", "Mock", MockProxy), +] + + +def apply(): + for mod_name, dotted_attr, spec in SHIMS: + try: + mod = importlib.import_module(mod_name) + except Exception: + if mod_name.startswith(("toga", "yourpackageprefix")): + mod = types.ModuleType(mod_name) + sys.modules[mod_name] = mod + else: + raise + + parts = dotted_attr.split(".") + target = mod + for part in parts[:-1]: + if not hasattr(target, part): + setattr(target, part, types.SimpleNamespace()) + target = getattr(target, part) + + setattr(target, parts[-1], spec) + + +apply() diff --git a/web-testbed/tests/tests_backend/widgets/__init__.py b/web-testbed/tests/tests_backend/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-testbed/tests/tests_backend/widgets/base.py b/web-testbed/tests/tests_backend/widgets/base.py new file mode 100644 index 0000000000..8c6806c243 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/base.py @@ -0,0 +1,17 @@ +class SimpleProbe: + page_provider = staticmethod(lambda: None) + + def _page(self): + return type(self).page_provider() + + def __init__(self, widget): + self.id = widget.id + self.dom_id = f"toga_{widget.id}" + + async def redraw(self, message=None, delay=0): + page = self._page() + + # Yield to the event loop so on_press handler runs before assertions + # (wait_for_timeout(0) is a no-op tick in Playwright) + print("Waiting for redraw" if message is None else message) + page.run_coro(lambda p: p.wait_for_timeout(delay)) diff --git a/web-testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py new file mode 100644 index 0000000000..ef539358fc --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -0,0 +1,57 @@ +import re + +from travertino.colors import rgba + +from .base import SimpleProbe + +_rgb_re = re.compile(r"rgba?\(([^)]+)\)") + + +def css_to_travertino(css: str): + if not css or css == "transparent": + return None + m = _rgb_re.search(css) + if not m: + return None + parts = [p.strip() for p in m.group(1).split(",")] + r, g, b = map(int, parts[:3]) + a = float(parts[3]) if len(parts) == 4 else 1.0 + return rgba(r, g, b, a) + + +class ButtonProbe(SimpleProbe): + @property + def text(self): + page = self._page() + return page.run_coro(lambda p: p.locator(f"#{self.dom_id}").text_content()) + + @property + def height(self): + page = self._page() + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + return None if box is None else box["height"] + + async def press(self): + page = self._page() + + # Click/press + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").click()) + + @property + def background_color(self): + # Return a Color-like object with .r/.g/.b/.a so the stock assertions + # (which expect Toga Color objects) work unchanged. + + page = self._page() + css = page.run_coro( + lambda p: p.evaluate( + """(selector) => { + const el = document.querySelector(selector); + if (!el) return null; + const cs = getComputedStyle(el); + return cs.backgroundColor; + }""", + f"#{self.dom_id}", + ) + ) + return css_to_travertino(css) diff --git a/web-testbed/tests/tests_backend/widgets/dateinput.py b/web-testbed/tests/tests_backend/widgets/dateinput.py new file mode 100644 index 0000000000..c4a26b3fe6 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/dateinput.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import date, timedelta + +from .base import SimpleProbe + + +class DateInputProbe(SimpleProbe): + def __init__(self, widget): + super().__init__(widget) + self.widget = widget + # If getattr works, we assume limits are supported. + try: + _ = widget.min + _ = widget.max + self.supports_limits = True + except Exception: + self.supports_limits = False + + @property + def value(self) -> date | None: + return self.widget.value + + @property + def min_value(self) -> date | None: + return self.widget.min + + @property + def max_value(self) -> date | None: + return self.widget.max + + async def change(self, delta_days: int): + if callable(getattr(self, "redraw", None)): + await self.redraw(f"DateInput change {delta_days:+d} days") + + cur = self.widget.value or date.today() + candidate = cur + timedelta(days=int(delta_days)) + + dmin, dmax = self.widget.min, self.widget.max + if dmin and candidate < dmin: + candidate = dmin + if dmax and candidate > dmax: + candidate = dmax + + self.widget.value = candidate diff --git a/web-testbed/tests/tests_backend/widgets/label.py b/web-testbed/tests/tests_backend/widgets/label.py new file mode 100644 index 0000000000..fc74d94ba5 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/label.py @@ -0,0 +1,40 @@ +from .base import SimpleProbe + + +class LabelProbe(SimpleProbe): + def __init__(self, widget): + super().__init__(widget) + self._baseline_height = 0 + + @property + def text(self): + page = self._page() + return page.run_coro(lambda p: p.locator(f"#{self.dom_id}").text_content()) + + @property + def width(self): + page = self._page() + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + return None if box is None else box["width"] + + @property + def height(self): + page = self._page() + + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + h = 0 if box is None else box["height"] + + text = self.text or "" + lines = text.count("\n") + 1 + + if h > 0 and self._baseline_height == 0: + self._baseline_height = h + baseline = self._baseline_height or h or 0 + + if text == "": + return baseline + + if lines > 1 and baseline > 0: + return baseline * lines + + return h if h > 0 else baseline diff --git a/web-testbed/tests/tests_backend/widgets/passwordinput.py b/web-testbed/tests/tests_backend/widgets/passwordinput.py new file mode 100644 index 0000000000..2c7937cae9 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/passwordinput.py @@ -0,0 +1,5 @@ +from .textinput import TextInputProbe + + +class PasswordInputProbe(TextInputProbe): + pass diff --git a/web-testbed/tests/tests_backend/widgets/switch.py b/web-testbed/tests/tests_backend/widgets/switch.py new file mode 100644 index 0000000000..7c6f487a85 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/switch.py @@ -0,0 +1,18 @@ +from .base import SimpleProbe + + +class SwitchProbe(SimpleProbe): + @property + def text(self): + page = self._page() + return page.run_coro(lambda p: p.locator(f"#{self.dom_id}").text_content()) + + @property + def height(self): + page = self._page() + box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) + return None if box is None else box["height"] + + async def press(self): + page = self._page() + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").click()) diff --git a/web-testbed/tests/tests_backend/widgets/textinput.py b/web-testbed/tests/tests_backend/widgets/textinput.py new file mode 100644 index 0000000000..89099dad78 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/textinput.py @@ -0,0 +1,108 @@ +from .base import SimpleProbe + + +class TextInputProbe(SimpleProbe): + def __init__(self, widget): + super().__init__(widget) + self.widget = widget + self._last_remote_value = self._read_remote_value() + + def _read_remote_value(self) -> str: + return self.widget._eval_and_return(f"{self.widget.js_ref}.value") + + @property + def value(self): + page = self._page() + + def _run(p): + async def steps(): + root = p.locator(f"#{self.dom_id}") + inner = root.locator("input,textarea").first + target = inner if (await inner.count()) > 0 else root + return await target.input_value() + + return steps() + + return page.run_coro(_run) + + @property + def value_hidden(self) -> bool: + page = self._page() + + def _run(p): + async def steps(): + root = p.locator(f"#{self.dom_id}") + inner = root.locator("input,textarea").first + target = inner if (await inner.count()) > 0 else root + + # Native password inputs + t = await target.get_attribute("type") + if (t or "").lower() == "password": + return True + + return steps() + + return bool(page.run_coro(_run)) + + async def type_character(self, ch: str): + page = self._page() + + def _run(p): + async def steps(): + root = p.locator(f"#{self.dom_id}") + target = ( + (await root.locator("input,textarea").first.count()) + and root.locator("input,textarea").first + or root + ) + try: + await target.focus() + except Exception: + pass + + if ch == "\n": + await target.press("Enter") + elif ch == "": + await target.press("Escape") + elif ch in ("", "\b"): + await target.press("Backspace") + else: + await target.type(ch) + + return steps() + + page.run_coro(_run) + + async def undo(self): + page = self._page() + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").press("Control+Z")) + + async def redo(self): + page = self._page() + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").press("Control+Y")) + + def set_cursor_at_end(self): + page = self._page() + page.run_coro( + lambda p: p.evaluate( + """(sel) => { + const root = document.querySelector(sel); + if (!root) return; + const el = root.matches('input,textarea') ? + root : root.querySelector('input,textarea'); + if (!el) return; + el.focus(); + const len = (el.value ?? '').length; + if (typeof el.setSelectionRange === 'function') { + el.setSelectionRange(len, len); + } + }""", + f"#{self.dom_id}", + ) + ) + + async def redraw(self, _msg: str = ""): + # allow a tick + page = self._page() + page.run_coro(lambda p: p.wait_for_timeout(0)) + self._last_remote_value = self._read_remote_value() diff --git a/web-testbed/tests/tests_backend/widgets/timeinput.py b/web-testbed/tests/tests_backend/widgets/timeinput.py new file mode 100644 index 0000000000..ff10367a2f --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/timeinput.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, time, timedelta + +from .base import SimpleProbe + + +class TimeInputProbe(SimpleProbe): + def __init__(self, widget): + super().__init__(widget) + self.widget = widget + + prev = widget.value + try: + widget.value = time(12, 34, 56) + self.supports_seconds = widget.value and widget.value.second == 56 + except Exception: + self.supports_seconds = False + finally: + widget.value = prev + + @property + def value(self) -> time | None: + return self.widget.value + + async def change(self, delta_minutes: int): + if callable(getattr(self, "redraw", None)): + await self.redraw(f"TimeInput change {delta_minutes:+d} min") + + cur = self.widget.value or self.widget.min or time(0, 0, 0) + new_dt = datetime(2000, 1, 1, cur.hour, cur.minute, cur.second) + timedelta( + minutes=int(delta_minutes) + ) + t = time(new_dt.hour, new_dt.minute, new_dt.second) + tmin, tmax = self.widget.min, self.widget.max + + if tmin and t < tmin: + t = tmin + if tmax and t > tmax: + t = tmax + if not self.supports_seconds: + t = t.replace(second=0) + + self.widget.value = t + + # fire handler like a UI event + handler = getattr(self.widget, "on_change", None) + if callable(handler): + try: + handler() + except TypeError: + handler(self.widget) + + async def wait_for_change( + self, before: time | None, timeout: float = 2.0, interval: float = 0.05 + ): + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + if self.widget.value != before: + return + await asyncio.sleep(interval) + raise AssertionError("TimeInput value did not change within timeout") diff --git a/web-testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py new file mode 100644 index 0000000000..c8371d6ab8 --- /dev/null +++ b/web-testbed/tests/widgets/conftest.py @@ -0,0 +1,46 @@ +from unittest.mock import Mock + +import pytest +from probe import get_probe + +import toga + + +@pytest.fixture +async def widget(): + raise NotImplementedError("test modules must define a `widget` fixture") + + +@pytest.fixture +async def probe(main_window, widget): + old_content = main_window.content + box = toga.Box(children=[widget]) + main_window.content = box + probe = get_probe(widget) + yield probe + main_window.content = old_content + + +@pytest.fixture +async def other(widget): + """A separate widget that can take focus""" + other = toga.TextInput() + widget.parent.add(other) + return other + + +@pytest.fixture(params=[True, False]) +async def focused(request, widget, other): + if request.param: + widget.focus() + else: + other.focus() + return request.param + + +@pytest.fixture +async def on_change(widget): + handler = Mock() + widget.on_change = handler + handler.assert_not_called() + return handler diff --git a/web-testbed/tests/widgets/probe.py b/web-testbed/tests/widgets/probe.py new file mode 100644 index 0000000000..9a00c3ecee --- /dev/null +++ b/web-testbed/tests/widgets/probe.py @@ -0,0 +1,7 @@ +from importlib import import_module + + +def get_probe(widget): + name = type(widget).__name__ + module = import_module(f"tests.tests_backend.widgets.{name.lower()}") + return getattr(module, f"{name}Probe")(widget) diff --git a/web-testbed/tests/widgets/properties.py b/web-testbed/tests/widgets/properties.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py new file mode 100644 index 0000000000..88cf0707b3 --- /dev/null +++ b/web-testbed/tests/widgets/test_button.py @@ -0,0 +1,58 @@ +from unittest.mock import Mock + +from pytest import approx, fixture +from tests.assertions import assert_background_color +from tests.data import TEXTS + +import toga + +TRANSPARENT = "transparent" + + +@fixture +async def widget(): + return toga.Button("Hello") + + +async def test_text(widget, probe): + "The text displayed on a button can be changed" + initial_height = probe.height + + for text in TEXTS: + widget.text = text + + await probe.redraw(f"Button text should be {text}") + + # Text after a newline will be stripped. + assert isinstance(widget.text, str) + expected = str(text).split("\n")[0] + assert widget.text == expected + assert probe.text == expected + # GTK rendering can result in a very minor change in button height + assert probe.height == approx(initial_height, abs=1) + + +async def test_press(widget, probe): + # Press the button before installing a handler + await probe.press() + + # Set up a mock handler, and press the button again. + # Changed to MockProxy - objects created in test suite need a proxy + # to one in the remote web app. + handler = Mock() + widget.on_press = handler + await probe.press() + + await probe.redraw("Button should be pressed") + + handler.assert_called_once_with(widget) + + +async def test_background_color_transparent(widget, probe): + "Buttons treat background transparency as a color reset." + del widget.style.background_color + original_background_color = probe.background_color + + widget.style.background_color = TRANSPARENT + await probe.redraw("Button background color should be reset to the default color") + assert_background_color(probe.background_color, original_background_color) diff --git a/web-testbed/tests/widgets/test_dateinput.py b/web-testbed/tests/widgets/test_dateinput.py new file mode 100644 index 0000000000..9eaa8b8fe6 --- /dev/null +++ b/web-testbed/tests/widgets/test_dateinput.py @@ -0,0 +1,178 @@ +from datetime import date, datetime, timedelta +from unittest.mock import Mock, call + +from pytest import fixture + +import toga + +# When setting `value` to None, how close the resulting value must be to the current +# time. This allows for the delay between setting the value and getting it, which can be +# a long time on a mobile emulator. +NONE_ACCURACY = timedelta(seconds=1) + + +@fixture +async def initial_value(widget): + value = widget.value = date(2023, 5, 25) + return value + + +@fixture +async def min_value(widget): + return date(1800, 1, 1) + + +@fixture +async def max_value(widget): + return date(8999, 12, 31) + + +@fixture +def values(): + return [ + date(1800, 1, 1), + date(1960, 12, 31), + date(2020, 2, 29), # Leap day + date(2100, 1, 1), + date(8999, 12, 31), + ] + + +@fixture +def normalize(): + """Returns a function that converts a datetime or date into the date that would be + returned by the widget.""" + + def normalize_date(value): + if isinstance(value, datetime): + return value.date() + elif isinstance(value, date): + return value + else: + raise TypeError(value) + + return normalize_date + + +@fixture +def assert_none_value(normalize): + def assert_approx_now(actual): + now = datetime.now() + min = normalize(now - NONE_ACCURACY) + max = normalize(now) + assert min <= actual <= max + + return assert_approx_now + + +@fixture +async def widget(): + return toga.DateInput() + + +async def test_init(): + "Properties can be set in the constructor" + + value = date(1999, 12, 31) + min = date(1999, 12, 30) + max = date(2000, 1, 1) + on_change = Mock() + + widget = toga.DateInput(value=value, min=min, max=max, on_change=on_change) + assert widget.value == value + assert widget.min == min + assert widget.max == max + assert widget.on_change._raw is on_change + + +async def test_value(widget, probe, normalize, assert_none_value, values, on_change): + "The value can be changed" + assert_none_value(widget.value) + + for expected in values + [None]: + widget.value = expected + actual = widget.value + if expected is None: + assert_none_value(actual) + else: + assert actual == normalize(expected) + + await probe.redraw(f"Value set to {expected}") + assert probe.value == actual # `expected` may be None + on_change.assert_called_once_with(widget) + on_change.reset_mock() + + +async def test_change(widget, probe, on_change): + "The on_change handler is triggered on user input" + + widget.min = date(2023, 5, 17) + widget.value = date(2023, 5, 20) + widget.max = date(2023, 5, 23) + + on_change.reset_mock() + + for i in range(1, 4): + await probe.change(1) + expected = date(2023, 5, 20 + i) + assert widget.value == expected + assert probe.value == expected + assert on_change.mock_calls == [call(widget)] * i + + # Can't go past the maximum + assert widget.value == widget.max + await probe.change(1) + assert widget.value == widget.max + + widget.value = date(2023, 5, 20) + on_change.reset_mock() + + for i in range(1, 4): + await probe.change(-1) + expected = date(2023, 5, 20 - i) + assert widget.value == expected + assert probe.value == expected + assert on_change.mock_calls == [call(widget)] * i + + # Can't go past the minimum + assert widget.value == widget.min + await probe.change(-1) + assert widget.value == widget.min + + +async def test_min(widget, probe, initial_value, min_value, values, normalize): + "The minimum can be changed" + value = normalize(initial_value) + if probe.supports_limits: + assert probe.min_value == normalize(min_value) + + for min in values: + widget.min = min + assert widget.min == normalize(min) + + if value < min: + value = normalize(min) + assert widget.value == value + + await probe.redraw(f"Minimum set to {min}") + if probe.supports_limits: + assert probe.min_value == normalize(min) + + +async def test_max(widget, probe, initial_value, max_value, values, normalize): + "The maximum can be changed" + value = normalize(initial_value) + if probe.supports_limits: + assert probe.max_value == normalize(max_value) + + for max in reversed(values): + widget.max = max + assert widget.max == normalize(max) + + if value > max: + value = normalize(max) + assert widget.value == value + + await probe.redraw(f"Maximum set to {max}") + if probe.supports_limits: + assert probe.max_value == normalize(max) diff --git a/web-testbed/tests/widgets/test_label.py b/web-testbed/tests/widgets/test_label.py new file mode 100644 index 0000000000..60d4bd44fd --- /dev/null +++ b/web-testbed/tests/widgets/test_label.py @@ -0,0 +1,48 @@ +from pytest import approx, fixture + +import toga + + +@fixture +async def widget(): + return toga.Label("hello, this is a label") + + +async def test_multiline(widget, probe): + """If the label contains multiline text, it resizes vertically.""" + + def make_lines(n): + return "\n".join(f"This is line {i}" for i in range(n)) + + widget.text = make_lines(1) + await probe.redraw("Label should be resized vertically") + line_height = probe.height + print(probe.height) + + # Label should have a significant width. + assert probe.width > 50 + + # Empty text should not cause the widget to collapse. + widget.text = "" + print(probe.height) + + await probe.redraw("Label text should be empty") + assert probe.height == line_height + # Label should have almost 0 width + assert probe.width < 10 + + widget.text = make_lines(2) + await probe.redraw("Label text should be changed to 2 lines") + assert probe.height == approx(line_height * 2, rel=0.1) + line_spacing = probe.height - (line_height * 2) + + for n in range(3, 6): + widget.text = make_lines(n) + await probe.redraw(f"Label text should be changed to {n} lines") + # Label height should reflect the number of lines + assert probe.height == approx( + (line_height * n) + (line_spacing * (n - 1)), + rel=0.1, + ) + # Label should have a significant width. + assert probe.width > 50 diff --git a/web-testbed/tests/widgets/test_passwordinput.py b/web-testbed/tests/widgets/test_passwordinput.py new file mode 100644 index 0000000000..23ed89dd99 --- /dev/null +++ b/web-testbed/tests/widgets/test_passwordinput.py @@ -0,0 +1,27 @@ +import pytest + +import toga + + +@pytest.fixture +async def widget(): + return toga.PasswordInput(value="sekrit") + + +@pytest.fixture +def verify_font_sizes(): + # We can't verify font width inside the TextInput + return False, True + + +async def test_value_hidden(widget, probe): + "Value should always be hidden in a PasswordInput" + assert probe.value_hidden + + widget.value = "" + await probe.redraw("Value changed from non-empty to empty") + assert probe.value_hidden + + widget.value = "something" + await probe.redraw("Value changed from empty to non-empty") + assert probe.value_hidden diff --git a/web-testbed/tests/widgets/test_switch.py b/web-testbed/tests/widgets/test_switch.py new file mode 100644 index 0000000000..f35a7388a3 --- /dev/null +++ b/web-testbed/tests/widgets/test_switch.py @@ -0,0 +1,75 @@ +from unittest.mock import Mock, call + +from pytest import fixture + +import toga + +from tests.data import TEXTS + + + +# Switches can't be given focus on mobile, or on GTK +#from tests.properties import test_focus # noqa: F401 + + +@fixture +async def widget(): + return toga.Switch("Hello") + + +async def test_text(widget, probe): + "The text displayed on a switch can be changed" + initial_height = probe.height + + for text in TEXTS: + widget.text = text + await probe.redraw(f"Switch text should be {text}") + + # Text after a newline will be stripped. + expected = str(text).split("\n")[0] + assert isinstance(widget.text, str) + assert widget.text == expected + assert probe.text == expected + assert probe.height == initial_height + + +async def test_press(widget, probe): + # Press the button before installing a handler + await probe.press() + await probe.redraw("Switch should be pressed") + + # Set up a mock handler, and press the button again. + handler = Mock() + widget.on_change = handler + + await probe.press() + await probe.redraw("Switch should be pressed again") + handler.assert_called_once_with(widget) + +async def test_change_value(widget, probe): + "If the value of the widget is changed, on_change is invoked" + handler = Mock() + widget.on_change = handler + + # Reset the mock; assigning the handler causes it to be evaluated as a bool + handler.reset_mock() + + # Set the value of the switch + widget.value = True + await probe.redraw("Switch value should be True") + assert handler.mock_calls == [call(widget)] + + # Set the value of the switch to the same value + widget.value = True + await probe.redraw("Switch value should be True again") + assert handler.mock_calls == [call(widget)] + + # Set the value of the switch to a different value + widget.value = False + await probe.redraw("Switch value should be changed to False") + assert handler.mock_calls == [call(widget)] * 2 + + # Toggle the switch value + widget.toggle() + await probe.redraw("Switch value should be toggled") + assert handler.mock_calls == [call(widget)] * 3 diff --git a/web-testbed/tests/widgets/test_textinput.py b/web-testbed/tests/widgets/test_textinput.py new file mode 100644 index 0000000000..54637a04e7 --- /dev/null +++ b/web-testbed/tests/widgets/test_textinput.py @@ -0,0 +1,287 @@ +from unittest.mock import Mock, call + +import pytest +from tests.data import TEXTS + +import toga +from toga.constants import CENTER +from toga.style.pack import RIGHT, SERIF + + +@pytest.fixture +async def widget(): + return toga.TextInput(value="Hello") + + +@pytest.fixture +def verify_vertical_text_align(): + return CENTER + + +@pytest.fixture +def verify_font_sizes(): + # We can't verify font width inside the TextInput + return False, True + + +@pytest.fixture +def verify_focus_handlers(): + return True + + +@pytest.fixture(params=["", "placeholder"]) +async def placeholder(request, widget): + widget.placeholder = request.param + + +async def test_value_not_hidden(widget, probe): + "Value should always be visible in a regular TextInput" + assert not probe.value_hidden + + widget.value = "" + await probe.redraw("Value changed from non-empty to empty") + assert not probe.value_hidden + + widget.value = "something" + await probe.redraw("Value changed from empty to non-empty") + assert not probe.value_hidden + + +async def test_on_change_programmatic(widget, probe, on_change, focused, placeholder): + "The on_change handler is triggered on programmatic changes" + # Non-empty to non-empty + widget.value = "This is new content." + await probe.redraw("Value has been set programmatically") + on_change.assert_called_once_with(widget) + on_change.reset_mock() + + # Non-empty to empty + widget.value = "" + await probe.redraw("Value has been cleared programmatically") + on_change.assert_called_once_with(widget) + on_change.reset_mock() + + # Empty to non-empty + widget.value = "And another thing" + await probe.redraw("Value has been set programmatically") + on_change.assert_called_once_with(widget) + on_change.reset_mock() + + +async def test_on_change_user(widget, probe, on_change): + "The on_change handler is triggered on user input" + # This test simulates typing, so the widget must be focused. + widget.focus() + widget.value = "" + on_change.reset_mock() + + for count, char in enumerate("Hello world", start=1): + await probe.type_character(char) + await probe.redraw(f"Typed {char!r}") + + # The number of events equals the number of characters typed. + assert on_change.mock_calls == [call(widget)] * count + expected = "Hello world"[:count] + assert probe.value == expected + assert widget.value == expected + + +@pytest.mark.parametrize( + "test_input", + [ + '""', + "''", + "--", + "---", + 'Humorless "test" input', + "Can't 'bee' bothered", + "Bee dashing--or fail miserably. --- No One Ever", + ], +) +async def test_quote_dash_substitution_disabled(widget, probe, on_change, test_input): + # This test simulates typing, so the widget must be focused. + widget.focus() + widget.value = "" + on_change.reset_mock() + + for count, char in enumerate(test_input, start=1): + await probe.type_character(char) + await probe.redraw(f"Typed {char!r}") + + # The number of events equals the number of characters typed. + assert on_change.mock_calls == [call(widget)] * count + expected = test_input[:count] + assert probe.value == expected + assert widget.value == expected + + +async def test_on_change_focus(widget, probe, on_change, focused, placeholder, other): + """The on_change handler is not triggered by focus changes, even if they cause a + placeholder to appear or disappear.""" + + def toggle_focus(): + nonlocal focused + if focused: + other.focus() + focused = False + else: + widget.focus() + focused = True + + widget.value = "" + on_change.assert_called_once_with(widget) + on_change.reset_mock() + toggle_focus() + await probe.redraw(f"Value is empty; focus toggled to {focused}") + on_change.assert_not_called() + + widget.value = "something" + on_change.assert_called_once_with(widget) + on_change.reset_mock() + toggle_focus() + await probe.redraw(f"Value is non-empty; focus toggled to {focused}") + on_change.assert_not_called() + + +async def test_on_confirm(widget, probe): + "The on_confirm handler is triggered when the user types Enter." + # Install a handler, and give the widget focus. + handler = Mock() + widget.on_confirm = handler + widget.focus() + + # Programmatic changes don't trigger the handler + widget.value = "Hello" + await probe.redraw("Value has been set") + assert handler.call_count == 0 + + for char in "Bye": + await probe.type_character(char) + await probe.redraw(f"Typed {char!r}") + + # The text hasn't been accepted + assert handler.call_count == 0 + + await probe.type_character("") + await probe.redraw("Typed escape") + + # The text hasn't been accepted + assert handler.call_count == 0 + + await probe.type_character("\n") + await probe.redraw("Typed newline") + + # The handler has been invoked + handler.assert_called_once_with(widget) + + +async def test_validation(widget, probe): + "Input is continuously validated" + + def even_sum_of_digits(text): + total = 0 + for char in text: + if char.isdigit(): + total = total + int(char) + + if total % 2 == 1: + return "Non-even digits" + else: + return None + + widget.validators = [even_sum_of_digits] + widget.value = "Test 1" + widget.focus() + + await probe.redraw("Text is initially invalid (1)") + assert not widget.is_valid + + widget.value = "" + await probe.redraw("Cleared content; now valid (0)") + assert widget.is_valid + + await probe.type_character("3") + await probe.redraw("Typed a 3; now invalid (3)") + assert not widget.is_valid + + await probe.type_character("1") + await probe.redraw("Typed a 1; now valid (4)") + assert widget.is_valid + + await probe.type_character("4") + await probe.redraw("Typed a 4; still valid (8)") + assert widget.is_valid + + await probe.type_character("3") + await probe.redraw("Typed a 3; now invalid (11)") + assert not widget.is_valid + + +async def test_text_value(widget, probe): + "The text value displayed on a widget can be changed" + for text in TEXTS: + widget.value = text + await probe.redraw(f"Widget value should be {str(text)!r}") + + assert widget.value == str(text).replace("\n", " ") + assert probe.value == str(text).replace("\n", " ") + + +async def test_undo_redo(widget, probe): + "The widget supports undo and redo." + + text_0 = str(widget.value) + text_extra = " World!" + text_1 = text_0 + text_extra + + widget.focus() + probe.set_cursor_at_end() + + # type more text + for char in text_extra: + await probe.type_character(char) + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == text_1 + assert probe.value == text_1 + + # undo + await probe.undo() + await probe.redraw(f"Widget value should be {text_0!r}") + assert widget.value == text_0 + assert probe.value == text_0 + + # redo + await probe.redo() + await probe.redraw(f"Widget value should be {text_1!r}") + assert widget.value == text_1 + assert probe.value == text_1 + + +async def test_no_event_on_initialization(widget, probe, on_change): + "The widget doesn't fire events on initialization." + # When the widget is created and added to a box, no on_change event is fired. + parent = toga.Box() + parent.add(widget) + on_change.assert_not_called() + on_change.reset_mock() + + +async def test_no_event_on_style_change(widget, probe, on_change): + "The widget doesn't fire on_change events on text style changes." + # font changes + widget.style.font_family = SERIF + await probe.redraw("Font style has been changed") + on_change.assert_not_called() + on_change.reset_mock() + + # text alignment changes + widget.style.text_align = RIGHT + await probe.redraw("Text alignment has been changed") + on_change.assert_not_called() + on_change.reset_mock() + + # text color changes + widget.style.color = "#0000FF" + await probe.redraw("Text color has been changed") + on_change.assert_not_called() + on_change.reset_mock() diff --git a/web-testbed/tests/widgets/test_timeinput.py b/web-testbed/tests/widgets/test_timeinput.py new file mode 100644 index 0000000000..10fcf7615a --- /dev/null +++ b/web-testbed/tests/widgets/test_timeinput.py @@ -0,0 +1,112 @@ +from datetime import datetime, time +from unittest.mock import Mock, call + +from pytest import fixture + +import toga + + +@fixture +async def initial_value(widget): + value = widget.value = time(12, 34, 56) + return value + + +@fixture +async def min_value(widget): + return time(0, 0, 0) + + +@fixture +async def max_value(widget): + return time(23, 59, 59) + + +@fixture +def values(): + return [ + time(0, 0, 0), + time(0, 0, 1), + time(12, 34, 56), + time(14, 59, 0), + time(23, 59, 59), + ] + + +@fixture +def normalize(probe): + """Returns a function that converts a datetime or time into the time that would be + returned by the widget.""" + + def normalize_time(value): + if isinstance(value, datetime): + value = value.time() + elif isinstance(value, time): + pass + else: + raise TypeError(value) + + replace_kwargs = {"microsecond": 0} + if not probe.supports_seconds: + replace_kwargs.update({"second": 0}) + return value.replace(**replace_kwargs) + + return normalize_time + + +@fixture +async def widget(): + return toga.TimeInput() + + +async def test_init(normalize): + "Properties can be set in the constructor" + + value = time(10, 10, 30) + min = time(2, 3, 4) + max = time(20, 30, 40) + on_change = Mock() + + widget = toga.TimeInput(value=value, min=min, max=max, on_change=on_change) + assert widget.value == normalize(value) + assert widget.min == normalize(min) + assert widget.max == normalize(max) + assert widget.on_change._raw is on_change + + +async def test_change(widget, probe, on_change): + "The on_change handler is triggered on user input" + + # The probe `change` method operates on minutes, because not all backends support + # seconds. + widget.min = time(5, 7) + widget.value = time(5, 10) + widget.max = time(5, 13) + on_change.reset_mock() + + for i in range(1, 4): + await probe.change(1) + expected = time(5, 10 + i) + assert widget.value == expected + assert probe.value == expected + assert on_change.mock_calls == [call(widget)] * i + + # Can't go past the maximum + assert widget.value == widget.max + await probe.change(1) + assert widget.value == widget.max + + widget.value = time(5, 10) + on_change.reset_mock() + + for i in range(1, 4): + await probe.change(-1) + expected = time(5, 10 - i) + assert widget.value == expected + assert probe.value == expected + assert on_change.mock_calls == [call(widget)] * i + + # Can't go past the minimum + assert widget.value == widget.min + await probe.change(-1) + assert widget.value == widget.min