Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
455 changes: 247 additions & 208 deletions web-testbed/src/testbed/web_test_harness.py

Large diffs are not rendered by default.

59 changes: 36 additions & 23 deletions web-testbed/tests/tests_backend/proxies/base_proxy.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import inspect


class ProxyProtocolError(RuntimeError):
# Raised when the remote bridge returns an invalid or unexpected payload.
pass


def _contains_callable(x):
if isinstance(x, BaseProxy): # allow remote refs through
return False
if callable(x):
return True
if isinstance(x, (list, tuple, set)):
return any(_contains_callable(i) for i in x)
if isinstance(x, dict):
return any(_contains_callable(k) or _contains_callable(v) for k, v in x.items())
return False


class BaseProxy:
# Remote pure expression proxy
# Attribute reads auto-realise primitives/containers, everything else stays proxied.
Expand Down Expand Up @@ -61,12 +52,17 @@ def __setattr__(self, name: str, value):
return object.__setattr__(self, name, value)

# keep local policy intact
if self._is_declared_local(name) or _contains_callable(value):
if self._is_declared_local(name):
self._local_attrs[name] = value
return

# RPC setattr
env = self._serialise_for_rpc(value, self._storage_expr)
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)

def __delattr__(self, name: str):
Expand Down Expand Up @@ -202,15 +198,6 @@ def _is_declared_local(self, name: str) -> bool:
or name in type(self)._local_whitelist
)

def declare_local(self, *names: str):
object.__getattribute__(self, "_local_names").update(names)

@classmethod
def declare_local_class(cls, *names: str):
wl = set(cls._local_whitelist)
wl.update(names)
cls._local_whitelist = wl

@staticmethod
def _extract_ref_from_expr(expr: str, storage_expr: str = "self.my_objs") -> str:
prefix = f"{storage_expr}["
Expand Down Expand Up @@ -271,6 +258,16 @@ def _serialise_for_rpc(self, v, storage_expr="self.my_objs"):
k_env = {"type": "str", "value": str(k)}
items.append([k_env, self._serialise_for_rpc(val, storage_expr)])
return {"type": "dict", "items": items}

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)}

Expand All @@ -287,3 +284,19 @@ def _rpc(self, op, **kwargs):
"(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)
92 changes: 60 additions & 32 deletions web-testbed/tests/tests_backend/proxies/object_proxies.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,60 @@
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"
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"
124 changes: 71 additions & 53 deletions web-testbed/tests/tests_backend/web_test_patch.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,71 @@
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, MockProxy
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),
("unittest.mock", "Mock", MockProxy),
]


def apply():
for mod_name, dotted_attr, spec in SHIMS:
mod = sys.modules.get(mod_name)
if mod is None:
mod = types.ModuleType(mod_name)
sys.modules[mod_name] = mod

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()
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,
LabelProxy,
MockProxy,
PasswordInputProxy,
SwitchProxy,
TextInputProxy,
)
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),
("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()
40 changes: 40 additions & 0 deletions web-testbed/tests/tests_backend/widgets/label.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions web-testbed/tests/tests_backend/widgets/passwordinput.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .textinput import TextInputProbe

class PasswordInputProbe(TextInputProbe):
pass
17 changes: 17 additions & 0 deletions web-testbed/tests/tests_backend/widgets/switch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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())
Loading