Skip to content

Conversation

Stringer90
Copy link
Contributor

This draft PR is for code review and feedback from @freakboy3742 in regards to @vt37 and my part of the Toga Curtin University capstone project 2025, where we test potential implementations of Toga web testing.

The source for this PR is our repository’s main branch here, and is where we do our development and testing. Please see here for more information on our part of capstone project and here for the issue for our part of the project.

These changes are not meant to be run, it is only a snapshot of our demo/sandbox repository, therefore CI failures are expected.

If you would like to run this test suite, please see the 'README' in our repository here.

We will update this PR over time, when our repository's main branch updates.

Refs #3545

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

git-subtree-dir: testbed/toga_web_testing
git-subtree-split: c653c6b77e0315323ebc80afaddee2bf2a71bdd9
@vt37
Copy link
Contributor

vt37 commented Aug 28, 2025

One small note to add, when running the test suite as described in the README, the tests should pass successfully.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First off - congratulations on getting this far :-) That you've been able to get anything working is a major accomplishment - this isn't a simple task.

Regarding the specifics of the PR - I've pushed a couple of updates to address some high level issues:

  1. The additions contained .pyc files. These are binary artefacts, and shouldn't be committed to version control. I actually don't know how you managed to add them, because the .gitignore file should have excluded them...
  2. There's lots of pre-commit violations - you should ensure your development environment is set up as described in the contribution guide
  3. The directory structure you committed was unnecessarily convoluted.

As an example of (3) - your README instructions include details about copying app.py to a new location, and manually installing dependencies. Briefcase gives you a directory structure that will allow you to run a project, and manage dependencies... why not present the code in that format, ready to use.

The testbed folder is an example of a working test project. Why not copy that structure verbatim?

I've left a couple of other comments inline; there's some organisational stuff that we need to nail down before we get too much further.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how you committed this file - anything in the __pycache__ file should be ignored. It's a binary artefact, and shouldn't be committed.

from tests.tests_backend.proxies.box_proxy import BoxProxy
#from ..tests_backend.proxies.box_proxy import BoxProxy

""" TODO: Don't enable until below is implemented.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triple quotes are a string; if you want to comment something out, use #

_inst = None
_lock = threading.Lock()

def __new__(cls):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than define a singleton, it may be better to handle this as a session-scoped fixture.

Singletons are a bit of an anti-pattern at the best of times; a session-scoped fixture means you get all the benefits of a single re-used fixture, without needing to implement a bunch of complex singleton logic. Plus, if you do need a second Page object for some reason, you can construct one that isn't a session-linked fixture.

except Exception:
self._alock = asyncio.Lock()
finally:
self._ready.set()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This except/finally block silently eats errors. If you haven't got Playwright correctly installed, the new_page() call fails - but this dutifully creates a lock and progresses, and you get a bunch of async errors that imply the page hasn't been created without any other errors.

(Ask me how I know... :-) )

I don't think the exception handling here is needed - if you get an exception creating the page, you want the fixture to explode as early (and as proximal to the cause of the error) as possible.

from ..page_singleton import BackgroundPage


class ButtonProbe:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused by how this ButtonProbe has been defined.

I can't deny it works - but it seems inconsistent with an approach that will work long term.

The Probe interface for any given widget (e.g., ButtonProbe) is something that is effectively defined by the testbed tests. If you look at the implementation of ButtonProbe for each other platform, the API is essentially identical - text, assert_no_icon(), assert_icon_size(), and so on.

I'd expect to see exactly the same thing here - a class that implements the required Probe interface. The implementation of the probe almost certainly involves calling the page object to retrieve key details (like the content of a text element) - but the API doesn't need to be run through __getattr__(). __getattr__() is only required if you want to make the object dynamically respond to any method... and the implementation here very clearly isn't doing that.

I'd expect the proxy to be dynamic - because we're looking to be able to retrieve an arbitrary attribute someattr, or call an arbitrary function somefunc() - but the Probe has a very strictly defined interface.


return page.eval_js("(code) => window.test_cmd(code)", code)

def setup(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between setup() and __init__()?

from ..page_singleton import BackgroundPage


class ButtonProxy:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be very surprised if we need a different Proxy class for every widget, except maybe at a very superficial level. The core implementation should be fundamentally the same - a __getattr__, a __setattr__, and maybe a __call__. We're looking for a dynamic wrapper around any object in the browser, not an interface that requires us to wrap every object individually.

@Stringer90
Copy link
Contributor Author

I believe the above modifications covers the changes you have asked for. Again, please let us know if you would like anything changed further. Any feedback would be appreciated.

Regarding the different proxies, only the Button proxy is dynamic at the moment. In the future, the App, MainWindow and Box proxies will also be made dynamic.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely some good progress here.

  • Using the Javascript interface to determine if the attribute on the remote side is callable is a neat approach, avoiding the need to pass in the object being proxied to the Proxy object itself.
  • The duplication of the widget index is a little concerning - there shouldn't be a need to do that. Toga already has a widget index; we should be using that.
  • I can see how the page object is being passed around - but it's a lot more complicated than it needs to be, and the approach you've taken undermines all the work that you've done making page a session-based fixture.
  • It's a little hard to tell how far you are down the "proxifyication" process - but assuming Button is a better representation than Box, it's definitely headed in the right direction; but there's still more abstraction that should be possible. ButtonProxy isn't adding anything that is "Button specific" - it should be possible to completely abstract that implementation of class construction.
  • You may want to consider whether the API you've got for test_cmd() is rich enough. It may be necessary to "serialize" results so that you know what return values for fuctions actually are. As it is, you're only really testing an attribute that returns a string; and that's fine because it's what the JS DOM is giving you. But if you're returning an integer, are you going to be able to tell the difference between 42 and "42"? If the return value of test_cmd() was {"int": 42} or {"type": "int", "value": 42}, you would - and that would also give you the fidelity to return objects by id.

Comment on lines 9 to 10
if getattr(self, "_init", False):
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still needed?

name = "testbed"
version = "0.0.1"

[project.optional-dependencies]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is no longer consistent with the install instructions. [dependency-groups] is the required header if you're installing pip install --group test. [project.optional-dependencies] would be consistent with pip install -e .[test]. The former (--group test) is the preferred usage; see PEP 735 for details.

Comment on lines 24 to 25
p = BackgroundPage()
yield p
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's no "post yield" behaviour, this can be a simple return p.

Comment on lines 28 to 33
@pytest.fixture(scope="session", autouse=True)
def _wire_page(page):
BaseProxy.page_provider = staticmethod(lambda: page)
BoxProxy.page_provider = staticmethod(lambda: page)
MainWindowProxy.page_provider = staticmethod(lambda: page)
ButtonProbe.page_provider = staticmethod(lambda: page)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a round-about way to get access to the page.

Why not make the page a fixture that is passed into the app fixture, pass the page as an attribute of the AppProxy when it is constructed, and then make the app fixture a dependency of every other widget?

Comment on lines 7 to 9
def __init__(self, widget):
object.__setattr__(self, "id", widget.id)
object.__setattr__(self, "dom_id", f"toga_{widget.id}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this abstraction needed? Why isn't it just self.id = ...?

Comment on lines 22 to 36
# Alternate Method (non-lambda)
"""
sel = f"#{self.dom_id}"

if name == "text":
async def _text(page):
return await page.locator(sel).inner_text()
return w.run_coro(_text)

if name == "height":
async def _height(page):
box = await page.locator(sel).bounding_box()
return None if box is None else box["height"]
return w.run_coro(_height)
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If code is no longer needed, it can be deleted. It should be in version control history if it needs to be resurrected - but in this case, I doubt it will be.

Comment on lines 7 to 8
async def widget():
return ButtonProxy()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a ButtonProxy needs access to page to work - why not use page as a fixture here (or, at least, app, from which you can get access to page).

Comment on lines 6 to 10
code = (
f"new_widget = toga.Button({repr(text)})\n"
"self.my_widgets[new_widget.id] = new_widget\n"
"result = new_widget.id"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is identical to what is used to create a remote Box, except for (a) the class name, and (b) the arguments. Why can't they use the same implementation?

await self._page.wait_for_timeout(7000)

await self._page.evaluate(
"(code) => window.test_cmd(code)", "self.my_widgets = {}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed? Based on this and subsequent usage, it looks like you're building an index of widgets by ID, stored on the web browser side of things - the app already has one of these - app.widgets.

Comment on lines 38 to 39
# everything else use text form (what Toga expects for .text, etc)
return repr(str(value))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this will be safe - is there there any reason to suspect a str() form of an arbitrary argument will be valid?

Stringer90 and others added 11 commits September 14, 2025 13:48
…idget objects and dynamic attributes, not tested specifically yet. Tried using the in-built toga app 'self.widgets' registry, but had some trouble, sticking with 'my_widgets' for now. Trouble may stem from widget ids being strings(?) so adding to dict and retrieving looks a bit different.
…)' in '_encode_value' in 'expr_proxy.py', otherwise the 'MyObject()' data for the button 'test_text' does not work.
…ating the in-built 'self.widgets' registry, did not go well, I have my suspicions why. Therefore will keep using 'my_widgets' for now.
…d-new-format

Merge to workspace from web-workspace/serialize-test-cmd-new-format
Merge Pull Request from workspace
@Stringer90
Copy link
Contributor Author

For this update, all of the button-specific test methods have been implemented and pass successfully (apart from test_icon(), since icons aren't fully implemented for the web backend yet). The BoxProxy has been 'proxified' and there is a new proxy structure, which allows for non-widget objects and object attributes. The test_cmd and base proxy methods have also been updated to handle more data types.

The current method for proxies accessing the Playwright page has not been altered (at least for now), as it allows for test methods and pytest fixtures to remain the same, and it gives our new AttributeProxy access.

For the object.__setattr__ and object.__getattribute__ uses, they are necessary to avoid recursion (previously caused test failures) and accidental remote calls when the test suite is running. We have also tried to implement the self.widgets in-built registry for toga widget objects, but it ended up not working. We will definitely try again in the future, but I have a suspicion why it doesn't work. This is why we started with our custom my_widgets dictionary as a replacement.

The other suggestions have been fixed/dealt with.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this update, all of the button-specific test methods have been implemented and pass successfully (apart from test_icon(), since icons aren't fully implemented for the web backend yet). The BoxProxy has been 'proxified' and there is a new proxy structure, which allows for non-widget objects and object attributes. The test_cmd and base proxy methods have also been updated to handle more data types.

I feel like we're still talking at crossed purposes when it comes to Proxies.

BoxProxy has a hard-coded implementation of add(). The purpose of a proxy is that it shouldn't have any hard-coded implementations - it should only require __getattr__ and __setattr__ handling so that any method invoked on the proxy will be invoked on the remote alternative of the object. If you require hard coding of any calls, then you haven't got a full proxy yet.

Another example of where this is going off the rails - the _is_function and _is_primitive_attr methods. These have been carved out as special cases - but they shouldn't be. They're proxies. If you get an attribute of an object, it returns another object. That object might be a primitive, or a widget, or a function, or a class... but that's entirely a question of serialisation. You have an object myobject, and you have code that says myobject(arg1, arg2), that's invoking the __call__() member method of the myobject object.

For the object.__setattr__ and object.__getattribute__ uses, they are necessary to avoid recursion (previously caused test failures) and accidental remote calls when the test suite is running.

I'll need to dig into this; needing access to __getattribute__ is often an example of a deeper architectural issue, but you're also at the edge of things where you might actually have a legitimate use. However, I think we need to get the basic proxying right first before I dig too deep into this.

My suggestion at this point is to step back from widgets entirely (or as much as possible), and focus on proxying completely generic attributes and objects. Don't worry about Toga - treat the proxy of the App object in the testbed as an entry point, and add an "attribute that is an integer", and an "attribute that is a string", and a "attribute that returns an object", and "a function call that accepts no arguments" and so on - that is, get the core of proxying working. Once that is in place, "a button proxy" is "a proxy of a method that returns a Button instance", and shouldn't require any additional work or special handling.

Comment on lines 22 to 23
# no-op
# await probe.redraw(f"Button text should be {text}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW - it's fine if this is a no-op; in which case, implement it as a no op. Add a redraw() method on the widget's probe that does nothing. In particular, it likely isn't a full no-op, because the "--slow" mode of test execution is likely a call to await asyncio.sleep(1).

code = (
"import uuid\n"
f"new_obj = {ctor_expr}({call_args})\n"
"key = str(uuid.uuid4())\n"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to invent a new unique ID for an object. id(myobj) will return a unique integer identifier.

from .base_proxy import BaseProxy


class NonWidgetProxy(BaseProxy):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There shouldn't be a need to differentiate Widgets and "non-widgets" - they're all just "objects" as far as Python is concerned.

Comment on lines 105 to 108
if name == "text":
rhs = repr(str(value))
else:
rhs = self._encode_value(value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is str special cased? Why can't it be handled as "yet another thing to encode"?

raise TypeError("not a handle")
key = h["id"]
if h.get("ns", "widgets") == "widgets":
from .widget_proxy import WidgetProxy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, inline imports are an indicator of a code smell - imports should be at the top of the page, unless there's a circular dependency issue - and if there's a circular dependency issue, you should be refactoring to avoid that issue.

return {k: self._unwrap(x) for k, x in v.items()}
return v

def _encode_value(self, value) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In terms of naming - it's worth maintaining parity in naming of APIs that are co-dependent. The opposite of 'encode' is 'decode', not "unwrap".

@Stringer90
Copy link
Contributor Author

This update is mainly to make each object proxy a full proxy, and to fix other suggestions in the feedback.

There are no hard-coded method implementations for any proxy, they should now be full proxies. They now only need to define a class name, for example class ButtonProxy(ObjectProxy): _ctor_expr = "toga.Button". There is a new ObjectProxy class to replace WidgetProxy and NonWidgetProxy, so that Toga widgets/objects are treated the exact same as any other Python object. In app.py, test_cmd is now only exposed when an environment variable TOGA_WEB_TESTING is set.

Apart from that, the inline import, object.__setattr__ and object.__getattribute__ uses have been removed. The other suggestions have been dealt with.

All 3 of the current toga.Button tests still pass.

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it's getting a lot closer to a workable solution.

My biggest concern at this point is the "line protocol". At present, it looks like it's essentially "passing blocks of executable code" back and forth - and that works, but it means there's lots of code "acting at a distance". If possible, it would be desirable to simplify the protocol so that the only information going back and forth are specific requests - essentially, instead of passing getattr(obj, name) as a string that is evaluated at the other side, you decompose that into a command (getattr), and a list of arguments (obj, name), and pass that down the wire.

self.main_window.show()

def cmd_test(self, code):
env = {"self": self, "toga": toga, "my_objs": self.my_objs, "Mock": Mock}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what you're looking for here is vars(). That's a complete state of all variables that are visible in the current scope.

def _serialise_payload(self, x):
# primitives
if x is None:
return {"type": "none", "value": None}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Value is likely redundant here.

Comment on lines 49 to 50
except Exception:
raise
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is effectively a no-op.

from .base import SimpleProbe


class _ColorLike:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason we can't use a Travertino Color or RGBA instance here?

Comment on lines 30 to 32
attr_expr = AttributeProxy(self, name)
ok, value = self._try_realise_value(attr_expr.js_ref)
return value if ok else attr_expr
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow what is going on here. The result of accessing an attribute is another object, which will either be a literal, or a proxy on an object instance. What is the role of an AttributeProxy here?

Comment on lines 10 to 11
async def widget():
return ButtonProxy("Hello")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something to investigate - can this be pushed into the backend so that it's the backend responsibility to create the object (e.g., something like test_backend.factory("Button")("Hello")). That would allow the test case to remain generic - the current test suite can be satisfied by literally instantiating toga.Button, but the web backend can instantiate ButtonProxy() (which also opens the door to removing the need for object_proxies (or at least moving that functionality to be clearly in the backend)

@Stringer90
Copy link
Contributor Author

There is now a script toga/web-testbed/run_tests.py, which automatically starts the testbed web app, runs the test suite, then ends the web app.

Toga objects in the test suite now resolve to their proxies (i.e. toga.Button -> ButtonProxy) through a custom pytest plugin web_test_patch.py which also handles Playwright page wiring. This is in the tests_backend directory, so the test suite should no longer have any mention of web-specific things and look like the actual test suite. This is imported in the top-level conftest.py. We have also made a web_test_harness.py to take out almost all web-specific stuff from the testbed app.py.

BaseProxy now returns a cached proxy when _deserialise_payload encounters a known remote ID and keeps Python-only values in a local shadow store: private attributes, explicitly declared locals, and any value (or container) containing Python callables are retained in _local_attrs, while only primitives/containers and remote proxies are forwarded across the bridge. These changes were made in response to errors when implementing more test methods for other widgets.

Other changes have been made in response to the feedback, which includes the part about AttributeProxy, which is now integrated into __getattr__.

Regarding the 'line protocol' suggestion, we could definitely investigate this method in the future.

@Stringer90
Copy link
Contributor Author

My biggest concern at this point is the "line protocol". At present, it looks like it's essentially "passing blocks of executable code" back and forth - and that works, but it means there's lots of code "acting at a distance". If possible, it would be desirable to simplify the protocol so that the only information going back and forth are specific requests - essentially, instead of passing getattr(obj, name) as a string that is evaluated at the other side, you decompose that into a command (getattr), and a list of arguments (obj, name), and pass that down the wire.

Is the above change what you had in mind for the "line protocol" request @freakboy3742 ?

@vt37
Copy link
Contributor

vt37 commented Oct 3, 2025

This PR extends my previous commits. The proxy can now serialize Python callables (validators/handlers) by sending their source, the host reconstructs them, so assigning widget.validators from the runner correctly drives host-side validation.

I’ve tested these changes with the existing suites: test_switch and test_textinput cover the caching/local-attribute behavior, and test_textinput also verifies callable transport.

A few failures are currently expected due to the implementation of the web backend. test_on_change_programmatic and test_on_change_focus failure happened because the web backend doesn’t invoke self.interface._value_changed() yet (it should be called in dom_onkeyup() and set_value()). Similarly, is_valid will fail until the web backend is implemented. For proxy testing, I added a minimal shim (set_error()/clear_error() toggling a flag; is_valid() returning its inverse). Once the backend wires _value_changed() and implements is_valid properly, these tests should pass.

vt37 and others added 3 commits October 3, 2025 09:02
Merged web-workspace/python-callables to workspace
Merged workspace to draft/toga-web-testing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants