From 6f5a0b9f9ed950af4b549c8361f81e3dd771f84e Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Thu, 28 Aug 2025 13:00:14 +0800 Subject: [PATCH 01/37] Squashed 'testbed/toga_web_testing/' content from commit c653c6b77 git-subtree-dir: testbed/toga_web_testing git-subtree-split: c653c6b77e0315323ebc80afaddee2bf2a71bdd9 --- README.md | 16 ++++ __init__.py | 0 __pycache__/__init__.cpython-312.pyc | Bin 0 -> 193 bytes app.py | 36 ++++++++ testbed/pyproject.toml | 7 ++ testbed/tests/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 207 bytes .../conftest.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 845 bytes .../tests/__pycache__/data.cpython-312.pyc | Bin 0 -> 670 bytes testbed/tests/conftest.py | 15 ++++ testbed/tests/data.py | 22 +++++ testbed/tests/tests_backend/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 211 bytes .../page_singleton.cpython-312.pyc | Bin 0 -> 7080 bytes testbed/tests/tests_backend/page_singleton.py | 80 ++++++++++++++++++ testbed/tests/tests_backend/probe.py | 1 + .../tests/tests_backend/proxies/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 219 bytes .../__pycache__/app_proxy.cpython-312.pyc | Bin 0 -> 705 bytes .../__pycache__/box_proxy.cpython-312.pyc | Bin 0 -> 1949 bytes .../__pycache__/button_proxy.cpython-312.pyc | Bin 0 -> 2364 bytes .../main_window_proxy.cpython-312.pyc | Bin 0 -> 1550 bytes .../tests/tests_backend/proxies/app_proxy.py | 7 ++ .../tests/tests_backend/proxies/box_proxy.py | 33 ++++++++ .../tests_backend/proxies/button_proxy.py | 60 +++++++++++++ .../proxies/main_window_proxy.py | 22 +++++ .../tests/tests_backend/widgets/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 219 bytes .../__pycache__/button.cpython-312.pyc | Bin 0 -> 1785 bytes testbed/tests/tests_backend/widgets/base.py | 1 + testbed/tests/tests_backend/widgets/button.py | 39 +++++++++ testbed/tests/tests_backend/widgets/label.py | 0 .../conftest.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 869 bytes .../widgets/__pycache__/probe.cpython-312.pyc | Bin 0 -> 737 bytes .../test_button.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 4795 bytes .../test_label.cpython-312-pytest-8.3.5.pyc | Bin 0 -> 217 bytes testbed/tests/widgets/conftest.py | 22 +++++ testbed/tests/widgets/probe.py | 7 ++ testbed/tests/widgets/properties.py | 1 + testbed/tests/widgets/test_button.py | 48 +++++++++++ testbed/tests/widgets/test_label.py | 0 41 files changed, 417 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 app.py create mode 100644 testbed/pyproject.toml create mode 100644 testbed/tests/__init__.py create mode 100644 testbed/tests/__pycache__/__init__.cpython-312.pyc create mode 100644 testbed/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc create mode 100644 testbed/tests/__pycache__/data.cpython-312.pyc create mode 100644 testbed/tests/conftest.py create mode 100644 testbed/tests/data.py create mode 100644 testbed/tests/tests_backend/__init__.py create mode 100644 testbed/tests/tests_backend/__pycache__/__init__.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/__pycache__/page_singleton.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/page_singleton.py create mode 100644 testbed/tests/tests_backend/probe.py create mode 100644 testbed/tests/tests_backend/proxies/__init__.py create mode 100644 testbed/tests/tests_backend/proxies/__pycache__/__init__.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/proxies/__pycache__/app_proxy.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/proxies/__pycache__/box_proxy.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/proxies/__pycache__/button_proxy.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/proxies/__pycache__/main_window_proxy.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/proxies/app_proxy.py create mode 100644 testbed/tests/tests_backend/proxies/box_proxy.py create mode 100644 testbed/tests/tests_backend/proxies/button_proxy.py create mode 100644 testbed/tests/tests_backend/proxies/main_window_proxy.py create mode 100644 testbed/tests/tests_backend/widgets/__init__.py create mode 100644 testbed/tests/tests_backend/widgets/__pycache__/__init__.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/widgets/__pycache__/button.cpython-312.pyc create mode 100644 testbed/tests/tests_backend/widgets/base.py create mode 100644 testbed/tests/tests_backend/widgets/button.py create mode 100644 testbed/tests/tests_backend/widgets/label.py create mode 100644 testbed/tests/widgets/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc create mode 100644 testbed/tests/widgets/__pycache__/probe.cpython-312.pyc create mode 100644 testbed/tests/widgets/__pycache__/test_button.cpython-312-pytest-8.3.5.pyc create mode 100644 testbed/tests/widgets/__pycache__/test_label.cpython-312-pytest-8.3.5.pyc create mode 100644 testbed/tests/widgets/conftest.py create mode 100644 testbed/tests/widgets/probe.py create mode 100644 testbed/tests/widgets/properties.py create mode 100644 testbed/tests/widgets/test_button.py create mode 100644 testbed/tests/widgets/test_label.py diff --git a/README.md b/README.md new file mode 100644 index 0000000000..bbe735eda8 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +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 repository in VSCode. +2. Ensure you have the following installed in your environment/venv (these are the versions I use): + - `playwright==1.51.0` + - `pytest==8.3.5` + - `pytest-asyncio==0.26.0` + - `pytest-playwright==0.7.0` +3. Open a Toga app in another VSCode window. +4. Copy the contents of this repository’s example `app.py` into your Toga project. +5. Update and build your Toga app for web. +6. Run your Toga app as a web app. +7. In this repository’s VSCode window: + - `cd` into the repository’s directory. + - Run: `pytest testbed/tests` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..387f40952a23e334a432da5c4c17f945f3bc4a8e GIT binary patch literal 193 zcmX@j%ge<81Q|WO86f&Gh(HIQS%4zb87dhx8U0o=6fpsLpFwJVB{*Bfgche36~{Oy z=H!&-#<=7sm*%GCl@!MW6y>KECFbU4=A{FfS*giDF`v}LqCAj5PGV(wQD%BZNla2| zYI$N&YJ7QWQhZ5jaS2d1CO$qhFS8^*Uaz3?7Kcr4eoARhs$CH)&_YHaE(S3^GBYwV I7BK@^0N$52N&o-= literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000000..7d746797fe --- /dev/null +++ b/app.py @@ -0,0 +1,36 @@ +import toga +from toga.style import Pack +from toga.style.pack import COLUMN, ROW, CENTER + +try: + import js +except ModuleNotFoundError: + js = None +try: + from pyodide.ffi import create_proxy +except ModuleNotFoundError: + pyodide = None + +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") + + if js is not None: + js.window.test_cmd = create_proxy(self.cmd_test) + + 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 cmd_test(self, code): + local_vars = {} + try: + exec(code, {'self': self, 'toga': toga}, local_vars) + return local_vars.get("result", "No result") + except Exception as e: + return f'Error: {e}' + +def main(): + return HelloWorld() \ No newline at end of file diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml new file mode 100644 index 0000000000..d65fc1c3e2 --- /dev/null +++ b/testbed/pyproject.toml @@ -0,0 +1,7 @@ +# Attempted integrating async operations, but it required calls like 'widget.text' +# to need 'await' when calling, which is not the case in Toga test methods. + +# Uncomment below if using async. + +[tool.pytest.ini_options] +asyncio_mode = "auto" \ No newline at end of file diff --git a/testbed/tests/__init__.py b/testbed/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/__pycache__/__init__.cpython-312.pyc b/testbed/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6aaa822e385c0e3481685754d30233e96e752282 GIT binary patch literal 207 zcmX@j%ge<81a24BXMpI(AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdmFH{~6Iz^FR2<`+ zn3GeQ8{?9nT$-DjS5h1kP?Voul$e{FnU@Y^W~C+r#e7l|i}FAMIf<3!MVaXtB{4~< zspW}9sqy8hN%1AA#U()57!aM5ngXVaW8&j8^D;}~8AQ(dc0g0eXl+|M0*9gQ$w?gI_vug%gphs40cWnY1P;+o=+3K$ijE|K|WRen? z=WscP(5?Pgh4oKGtt4&xO4c7<$&MF9*!x-bjHRcgusG#BOp`Vd74D}Q^%q3SXsG3S zvlgl`)sJ7flCFJOimekTVz&T~!~cMiNL8ph6})E~w)$+?4!edfh+nDh}2 zsE@HH)MJSZL|$32uz!*}JxU9YQMaHyH>W(utRroY2Fh~BPm^OA>13tQy)0u5WVZ0* zbyOqxX+r(>+ntGVht)9Puf|44Vztr+f-$^=0ov zVw*`S)?6y-@f)Z{Fh%y}yS+Di6Jta1x9h_04Z{dG;iCR9E8Ug1?q;Rh#N&8pIX*;( zQM`woinUcpSj$)yi{)|GW5S$6aaRVo+w=SvGzmHxK5IyM2d~AIxMUTaN?6C?X+C6B zZ7?5O{?TeawTiHJ<1`pX^Z{GPgxrxoMK#q4A-`-OqU&~9E!Z>qFBxT)y=04-<8CIA2c literal 0 HcmV?d00001 diff --git a/testbed/tests/__pycache__/data.cpython-312.pyc b/testbed/tests/__pycache__/data.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee7036142b6e45969cb4a46e14ba147224aa9860 GIT binary patch literal 670 zcmZ8ey^9k;6n`^2d)a)QXAmu2%LTbVKo6VqI1n!iMv$;D%x1d@R5Bdmq1fZ)ZNY+YK=G>;1dYHw5tA1{-Tt zWVL6=8DKaB4h!Ju9Iq>V;Fvp{aF=iK8uxgeH+U0+fb&W^Z1z#!3fhAzE}fZE+NxvR znd$B$4t>ng0{Tvl14nyD`Ge?K4AQ}-w~~Nk$cmJ@90j;L-#*Iyo=~a37QBT3X`)2@ zR9UJ2_jvzS_*e<4!hIIUSrXoz4zff{QWZXw)1hQZGM)?#wc}KHUobhbiJ0XxIUbJE zFcM(#yGwN@R!aLXV02QbF1xc~qF literal 0 HcmV?d00001 diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py new file mode 100644 index 0000000000..629ba65e96 --- /dev/null +++ b/testbed/tests/conftest.py @@ -0,0 +1,15 @@ +#from pytest import fixture, register_assert_rewrite, skip +#import toga + +import pytest +from .tests_backend.proxies.app_proxy import AppProxy + +@pytest.fixture(scope="session") +def app(): + # just return AppProxy + return AppProxy() + +@pytest.fixture(scope="session") +def main_window(app): + # return main window created by app proxy + return app.main_window \ No newline at end of file diff --git a/testbed/tests/data.py b/testbed/tests/data.py new file mode 100644 index 0000000000..c639ad4928 --- /dev/null +++ b/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(), +] \ No newline at end of file diff --git a/testbed/tests/tests_backend/__init__.py b/testbed/tests/tests_backend/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/tests_backend/__pycache__/__init__.cpython-312.pyc b/testbed/tests/tests_backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d5c9e197d1dab6193aee9ba1391d772a0d22931 GIT binary patch literal 211 zcmX@j%ge<81dF XD`EvYff0y{L5z>gjEsy$%s>_Zxfea; literal 0 HcmV?d00001 diff --git a/testbed/tests/tests_backend/__pycache__/page_singleton.cpython-312.pyc b/testbed/tests/tests_backend/__pycache__/page_singleton.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5415c645aa5c322f4c4d44eb8baf30052fb4199d GIT binary patch literal 7080 zcmd^EU2qfE6~0%yTCFYlPqq;q}5q?kYo{qpTTVkhJ;STGZtVdfqLW|D|Rq+!xy zZDG2+HrVh_r{X5Qz=xtIQoj$dyd7hZ`qHsAxIG_t+#JSCMTg64_Iv zr&zQCwTm23hiC&@BJw~>#Uh|(VlmKi(GIj?oE)P)&V9t^&`KW;1dsO0(V>X=c%WD6 z4$_?IIp7)i=6(U>6*@^qNhqh;WH%9tE#}WNt05n&QqAop6LggPpoBorE15Y)iIANQ zwi*7+ZM~bkeWZbGAPS9HC^-PL-o4*3<^YM&DcbF0HLDPg29HA4A5m1zstgTEvSt&6 zh!hh9pG9MWVMQwvgkU(JD1xBi52+vf;>6DOgHI`vtQ_1K2#1FT4(^HuhX$mGsvLY= zj`qrdfr0)=FR&pg2z7gY!?BO3gEJ?9|ED15`u%Jqc3atk*KP-uQt{7A}3}+xk z#Djxy0)hg)!jc+|v-(2nx6- zIuy!*nFLLMV+0POFsc~oC`k@XRl_DTx!c{GHklWMgjwp#)$!GAR6)6H5*h0R8^L%K%7>j_Q_H} z1o&yz&SwGV8Yf`Iu&zMSL5ziBRJ+I7GsQQScqXIgqNydTpky~I zUG-_#s-$bxW%Vb+Q^Qx|i6@`F?s^7#IA(aqS;vGU&DSLPn(KTmmhBVvG`}>-Fa3nC zk#WXguE9c}Ac}R{oC~%>W)0CF%7O<*Wzs0^rK6S~(YLu5uty>AW#&B2j4~m_e_k?= zGow>Jt7g-$l>Vr;6gFK5M!{5r9g#AduLO=ra6Hnpfv{W$vNTSXl%cRX#mbFXw1gCm zg~L}g=E#t$;F{|p8OmwHVM0i`!@y+fYoWJ-w;~)%X?41^C0W{XIS#uWpNe1gCR%r0 zRX=Xs^`7{f&~zwO`ouVc<|jT8e||KZSO_ z_;tP+&1dmMaklpR7r&qKtiR50Sk!Qxug{hj+1c#H4b$PlorG_$8vtY&fufwN-Q3;~ z!YePCZCNE`4JFJ4R$=HAvc!epBGR`kCMXKbl64SZmICG?^c8R^APsLDJ%O|W4n?TK zY+uN~Vh6H!2}nZ?&P#<{2Qh2j@>i#WyO0!Do>(YCER;Fl`ZHjaQJ**1aEMP`(d*wy>lq@#;QR z9c*uHh3F>`?u#mF`{wnV*Y}Tu>lyc~4n{@ESHEq0eXKtsMq@1)m>=W2YgPmT`s4-IHsI4~3m_G!FuSdPZP8fkVk zaKUIqm5!-Uo#n6^gB_HC8QU9GqguInPYP=PfCPSy#^a4Q1lG^6m+W4Q<2WIp$8$xU z$AZ$J+8>R`hzgAX!|Agaeh-z5D9-S7bUulHN^e@7APu>w99tyEWzQO4oKlk8fg+ zb+4~WdpnZej+A#>x^|mh?P<7l{NnMnXH(L%DdlNPyW3FSr_Lpl&F7jYThFy7yjxPv z_Ozot;b_lPr=9DP&UKf?lyhU+u`%J;n5|!#bgoP^?MXZ$q@0J+jzbB@p}*FwxU}}- z+8?%n+^$)klvJGUn&>+F*u-O>IO=9Sq^AAzncA-Tnc8jhGw!zePaW;>ZG`X8=bu4X z=)Os4k?+QW5FCAbmVwIK5H>E7_NDR;Y}g69dnG-!tkXp<(N3Oy?E&DXowU==PFFS| zzlC;|u+#0xzh6%~%h>lt>0RLeb?W|@$EU!a;-7*k< z4SKrBHM>qrbP9c-rb4HcI<3~Jt7tdFT&s6?ZfCA-vF_%`we41@`G{eF|A?#I?PWgl zG9Yu2(~l-t_eg&ajBy!(5(1x}9waB>p(fY!!^a@#oAqcGM%*hf$eK6CxW0mx#cv-c zp}cq|-OFqT6Xb%g2wy<-K5&)3s>^62);A| z2Sh=&HUk6Qb&Xf$s|)JlcI?{;MAwv=O4nq^WXJiA8TZ-?RhMcm)}-BQlkT;bpG>&h ze%ko4yDeSWc8eiZp2?1vI_768+vaDnG>@asb$qGp14$<;{62b~`b)W&8Q>;Y_^X(! z)L+iM%K-N)2h>gT)bHe`i#X)VDO3Z{kgwwWO}6P8>aTN5FS8-i!G;1-(&gbEq`z?@HSObR>%>CnVfp{NH!V~{eTDf*}pNsh11L!lW2w`|pKHD!BCrsq!F zK_v4AG+oOkM-<-nl6C&H%MZ9*+Ault(#ZUb%a6ER z+Axnp&W(IIhqydh;cuWXQ$NqW!T@)n#$U?(jQWeYpEJO{S-j;g zYGyn{+@Wk;DC;RCPXqB;^I^RKn;t;&43IkjTx#Mp^UhKCm9syJBJlpYt!wtZ@O-~- z9MAdPd2bxPO7Kwyd?OVEh_r=)s5lfx-XRFz9SVev78ye=xdzEMkRS&2cY27_@gE2S zlG+y)WeneBj5lP|LwO?-jK+0e)OZ~F78bt^L@UYs^C2jO!|*Q&Dq|?A$MFy8_E1JO z)Ct~Z$^jtrr^t+>e5`ZEQ8WI?+3tz%l%sa6^D~yUx@R4PtN4e7TC3+6;AWA#$??`@ zH>)hx+MB$^`VhUzTcKQ9VO@T+$zt7nljp3p|19UAMSr-{=Q7U~f2&rt@K3E4 window.test_cmd(code)", "self.my_widgets = {}") + + self._alock = asyncio.Lock() + except Exception as e: + self._alock = asyncio.Lock() + finally: + 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)) \ No newline at end of file diff --git a/testbed/tests/tests_backend/probe.py b/testbed/tests/tests_backend/probe.py new file mode 100644 index 0000000000..9029ba4a57 --- /dev/null +++ b/testbed/tests/tests_backend/probe.py @@ -0,0 +1 @@ +# BaseProbe, same with SimpleProbe, maybe don't implement until later. \ No newline at end of file diff --git a/testbed/tests/tests_backend/proxies/__init__.py b/testbed/tests/tests_backend/proxies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/tests_backend/proxies/__pycache__/__init__.cpython-312.pyc b/testbed/tests/tests_backend/proxies/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..deab23eff8085fb1ff4fd77eb8d204edbc89d987 GIT binary patch literal 219 zcmX@j%ge<81P8l%GeGoX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!s&cl92`x@7DvohZ z%*iRujd96OF3nBND=Cf%D9TSSO3cm8%u5F{vr?0RVm_&fMR_2BoW#oVqRjM+l9;5_ z)bhll)cEq$r1+B5;u4^243HfIVi(6JB_?O5=B30G6y;ZBrWVJ<$7kkcmc+;F6;$5h fu*uC&Da}c>D`EvYg%OC0L5z>gjEsy$%s>_Z8$>~I literal 0 HcmV?d00001 diff --git a/testbed/tests/tests_backend/proxies/__pycache__/app_proxy.cpython-312.pyc b/testbed/tests/tests_backend/proxies/__pycache__/app_proxy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a39b834c26db816fbe97f3d70615ce4461834e24 GIT binary patch literal 705 zcmYjP&1=*^6n~SCHMXuxt>{fs@DhZ*c_|`DMK5*{M1&9+W;5e9nlERvr0K~+{Tudg zC_VJA=&6^27Y%sQi#Ig~!IN+DQT9Q8?{j|dy~$jR#~Xn1?DFj39QAkY3}W?0b03Wr zaNs0{g!qJj0ndRO$H2`~qpR|b%JS@=1icedPxe`qzKK$vl`my>ScN@RtNIhrxah7x zfKNF11}DBLEX{h_SNnEldRFZ`$@7-w=)r!JMhS~umgjD+iFe&BjVo6i=9v<1f?a9Z zww;E0ltFY$6TjvT!U~G3+|cS3cw{DZiAQQniTJJ3HSWJpd%MAFC8P@WSR5~t;As{v z6Ok4wz?l}3B}tSn(0nICjJ*&{raBO_s+7^%_2$}YN4JLynXW}`99L~3e1sp9d!HxnrQpv)f7P$JNnoY3r&*?L6w{dn~sxp?`w9h49NHgnWbP NU&|spe*mF9^B=@KxhVhu literal 0 HcmV?d00001 diff --git a/testbed/tests/tests_backend/proxies/__pycache__/box_proxy.cpython-312.pyc b/testbed/tests/tests_backend/proxies/__pycache__/box_proxy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11a42ecca7ac2c88ab57696d5bae6e885a75fed8 GIT binary patch literal 1949 zcma)7O>7%Q6rR~1d*e8E;v`j*wy8sql2uiMREa}HB4`6#kf}g$a2<`-I}^vr+8buq zIJP!Y4?#*$rN99t5<&_mROP_6$8rLRi=%*2mLe5#;%4FlQct|uwVfsn5@YGjyq|gV z_Ip46X<#6RV7=Y^ZfQzE=ug3P7x2MqzYNt9Z0SXtr(L^fWI!=vE|o0GS!MQaCX-pv zGg-3}YT!_OoQR}Nf$Sg;E#p9bige%h5b0?>%*Q-Dj~0dzT9@FSdg@BRc+!5y9;5rC z3aZ2J0nX~OpCTq_hRXdOYDpjI4aE&5tFbYBfljtDfJPCxtf_Rai< z4&zS#wqaS-a(>=6t7TSkojd?gcUYLcL4)A+sKMfhZLk|PufV2iJHum>!enqMdmtpJ4TrjT>!%l7vs#S zTJDJe)X%QYm^Nj4dUh^dD^;jn%ZOSdW|@WxUFISWz6^f-DEq{)$g%^%ej!v=VRHD2 z@Nf!du7S33>7?>r=qTt+qybTGeFLo+8XVd7w!G%$ z*@y07a=xj}hmb!HX0P-<184smau{VW7DT$vtgm2ZLMqPJ$X~1LvL`J^&s+kQDqe5S zVi+a5nr#f7&B}D+;`BlW#>mWz8hLs_=i}f>R|3HKB{&Ba&o7740P#qeVSYtuCxAF2 zR2V$zpuV~It7yb~z{6YOw(};Cvq(QpT-{T?jeZl|tv6@hcy!^1vG2zo_5XOI8GkRt zKLSbEj&H@Aqpv+we$i$F@VWSSJthWB#V9lA!WbcCn^rAxjuY~6)v!7mXjE#OgzzNX z@%v57aGWx8muwiOj?4K)5C^3ag#7@84~RbHBCP=t#pk>VWCQ)GTyIMeHQgFSk)f6( ztHZnIF@mivcLm3>sQMOer&M(=bSC_Wp`H+xN*tJ_7IW=N2yyU;X!w(deeLz|W9GOH a3emt$3P?-F82^s0{i96axnl$*xbrXZy}+UX literal 0 HcmV?d00001 diff --git a/testbed/tests/tests_backend/proxies/__pycache__/button_proxy.cpython-312.pyc b/testbed/tests/tests_backend/proxies/__pycache__/button_proxy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b4d48083a448ee1bd32c730d54ec93e3572165d GIT binary patch literal 2364 zcma)8U1$_n6u$GfKglK=btIZryZ#Z!vX<ri*t@qe|7??=ODYx zrr8-PJaJ{(~C<0{Y5T)3rDxL^O zC(V)@(w-Efp|v|BX+o;c$}T>`9}Us<3ker)BG?0Mu%m4YHAxXE@t^UB9k%R*921lw?D`4`ta9?n6m7l>6jCWH!=}io;>N?o5?NBz(3&fP9fD zj;~T5LQ17eHN9%m0&|_Kb@5udE~Um(FW)ez}pwm8{5xT=0U7ymm(#*R0y{CMf7 zLr>HvZtL=wkL#^dnYANV?;gDC+&g}!zP5e;n!#GrH&zX6F1j93;)(Ua7zmYY-@l+O z>{-k;)n=rbUmjiF-AYc($-l%Bw-dJ#t(_;9<)^W6j%>;I(iBPQlbM6^)?|h@3SF&3 zXgJy)KoJp%@<5)I8sh7S4n&edid-U^JXIgz%&Ax|aA7M+M(fh(wKPWxYqHM*u7d7U zith<_5n#R-MGD0M5Ds1pGvwz7n_QbN&@Z;+sbe^T=RO6oHBajilGGN}N5cEtpu#L5`MNI$ zAT0JEC{5L=&8qFn0uN ziiLVY*T1S5R$$>n5Zc3H%>;C zv%={_-0}@ zAqy22jG%~k*n@cUA`5z5PkI!*q%6{PXhG0RZ`j}9ATX|uf7}oyLjJ-$CiLJ1XI;t6#@AXKE1D|%905{O9NBvjreRM{2N zjJn*<8Tos-GK6^XvSokR;z8G^*Q^%1C!}WeaKbz zsZf`gOvP=oJB_X@OlcS9Q&XKv^D%wdP-5+E%kkfHd>ZtU4F|6#lOAnYk#1YQ-eS?k zkbxff5%Z&pzLqMw*9{}R$#hH4nyuK_el`#xK#fd(0I@@&nXjFlBa4JN>wHU~!j?D` z+RB`Bs#0+%W+-k+L+R#1l+U8F{Ojb%)>bp`(H!M6eRtZ=mOPY(vN_vtC|@UrR2w|a zS=e+Qze7Kp%2eJV+WKO`f635IwKZFrtQ(xVdOYs5K91KoM#_f zuGtPbZWiZF(`P-?j75jSMaZ~KEO&qfv5ZCITr=1-9U3bgWI~8ZI*x~5tX*on8!{d? z)-2cUdW|cA-Syyd!UkMki(8)O_$^S|%m&+A%;G*8T&v&XPHQ7-G#TqzoS8k=G_hgu zHgIk-n#^G;%_gvu`Lux@I4o?;p6heW6 z86O4GCx%=bJpVYd%y`rX7ExeB`2K}~c;N;=1_cc{VF}0Yh8IyBPpykE#mA5b3EEsk zUkOBr-69$IGMdUD9t-~ojDHctf8bwQ-E)3+e{%QMe|>4J)!;7F!k2~53geYm_T=By zRlWjQH6tIZz*Wm*K-LA*^a9#-Q7@S0M_tREdVtR~u)(ZCuN}ste$rN)gWn@!{CkpA zOZ!DMISM>RcoD=l`9pr=Po;QYRF##9L~@IVs2!F`uK4NtC+qh`NvWc}oM>q+tWblu zvxu+841vWiivm9d^CUQvmzHeA&*Bo#lXMWPucgPRavf~=7li0MlmtPzOP;$+&ix~w L5ULLdh$QV_x0G|< literal 0 HcmV?d00001 diff --git a/testbed/tests/tests_backend/proxies/app_proxy.py b/testbed/tests/tests_backend/proxies/app_proxy.py new file mode 100644 index 0000000000..14fe18d488 --- /dev/null +++ b/testbed/tests/tests_backend/proxies/app_proxy.py @@ -0,0 +1,7 @@ +from .main_window_proxy import MainWindowProxy + +class AppProxy: + """Minimal app proxy: only expose main_window.""" + @property + def main_window(self): + return MainWindowProxy() \ No newline at end of file diff --git a/testbed/tests/tests_backend/proxies/box_proxy.py b/testbed/tests/tests_backend/proxies/box_proxy.py new file mode 100644 index 0000000000..0124b17779 --- /dev/null +++ b/testbed/tests/tests_backend/proxies/box_proxy.py @@ -0,0 +1,33 @@ +from ..page_singleton import BackgroundPage + +class BoxProxy: + """Proxy for toga.Box(children=[...]).""" + def __init__(self, children=None): + #Create box object remotely + self.id = self._create_remote_box() + #If there's children, add them + if children: + for child in children: + self.add(child) + + @classmethod + def _from_id(cls, box_id: str): + obj = cls.__new__(cls) + object.__setattr__(obj, "id", box_id) + return obj + + def _create_remote_box(self): + page = BackgroundPage.get() + code = ( + "new_box = toga.Box()\n" + "self.my_widgets[new_box.id] = new_box\n" + "result = new_box.id" + ) + return page.eval_js("(code) => window.test_cmd(code)", code) + + def add(self, widget): + page = BackgroundPage.get() + code = ( + f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" + ) + page.eval_js("(code) => window.test_cmd(code)", code) \ No newline at end of file diff --git a/testbed/tests/tests_backend/proxies/button_proxy.py b/testbed/tests/tests_backend/proxies/button_proxy.py new file mode 100644 index 0000000000..cfcc80ec62 --- /dev/null +++ b/testbed/tests/tests_backend/proxies/button_proxy.py @@ -0,0 +1,60 @@ +from ..page_singleton import BackgroundPage +import json + +class ButtonProxy: + def __init__(self): + object.__setattr__(self, "_inited", False) + + button_id = self.setup() + object.__setattr__(self, "id", button_id) + + object.__setattr__(self, "_inited", True) + + def __setattr__(self, name, value): + page = BackgroundPage.get() + widget_id = object.__getattribute__(self, "id") + + # METHOD 1 (working) + + literal = repr(str(value)) if not isinstance(value, (int, float, bool, type(None))) else repr(value) + + # METHOD 2 (working) + """ + try: + literal = json.dumps(value) + except TypeError: + literal = json.dumps(str(value)) + """ + # METHOD 3 (working) + """ + if name == "text": + literal = repr(str(value)) + else: + try: + literal = json.dumps(value) + except TypeError: + literal = repr(value) + """ + + code = f"self.my_widgets[{widget_id!r}].{name} = {literal}" + page.eval_js("(code) => window.test_cmd(code)", code) + + def __getattr__(self, name): + page = BackgroundPage.get() + + code = ( + f"result = self.my_widgets['{self.id}'].{name}" + ) + + return page.eval_js("(code) => window.test_cmd(code)", code) + + def setup(self): + page = BackgroundPage.get() + code = ( + "new_widget = toga.Button('Hello')\n" + "self.my_widgets[new_widget.id] = new_widget\n" + "result = new_widget.id" + ) + return page.eval_js("(code) => window.test_cmd(code)", code) + + diff --git a/testbed/tests/tests_backend/proxies/main_window_proxy.py b/testbed/tests/tests_backend/proxies/main_window_proxy.py new file mode 100644 index 0000000000..78246d29b2 --- /dev/null +++ b/testbed/tests/tests_backend/proxies/main_window_proxy.py @@ -0,0 +1,22 @@ +from ..page_singleton import BackgroundPage +from .box_proxy import BoxProxy + +class MainWindowProxy: + """Proxy that can get/set content. Content must be a BoxProxy.""" + + @property + def content(self): + page = BackgroundPage.get() + code = "result = self.main_window.content.id" + box_id = page.eval_js("(code) => window.test_cmd(code)", code) + if box_id is None: + return BoxProxy() + proxy = BoxProxy.__new__(BoxProxy) + proxy.id = box_id + return proxy + + @content.setter + def content(self, box_proxy): + page = BackgroundPage.get() + code = f"self.main_window.content = self.my_widgets['{box_proxy.id}']" + page.eval_js("(code) => window.test_cmd(code)", code) diff --git a/testbed/tests/tests_backend/widgets/__init__.py b/testbed/tests/tests_backend/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/tests_backend/widgets/__pycache__/__init__.cpython-312.pyc b/testbed/tests/tests_backend/widgets/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..411559c56259829227057939e1344a444a654aa1 GIT binary patch literal 219 zcmX@j%ge<81iQL=GeGoX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!s&cl92`x@7DvohZ z%*iRujd96OF3nBND=Cf%D9TSSO3cm8%u5F{vr?0RVm_&fMR_2BoW#oVqRjM+l9;5_ z)bhll)cEq$r1+B5;u4^243HfIVi(6JB_?O5=B31xXQrg50u6|d&&7%Q6rR~#yQ|i*lM<39ZG_tf#L6X-qk>dgT+|}L0l7g@FP7WMu6OF#-Jdc$ zCUJ122t}e?Pkzd7^hW+>jy>PwA+=^q)=tWxSMhYg}{pcJ4rH&}dVH4#f6R)D2EQ02gs;nvg zp$iqxU1x^51}$_?30$KfW%zZz3l!ZiYU$fu?^uL%y-Yv8$sKrZd84hkdjm4ge(pB zVKLQP6+QLd1hIl1sqr0kd`lg_bNcS^-A}%+ep}sE)BUSQez@FtYxB&;nMSIW%KWUJ z?|>tE4p$K`!1X&&J_l;>9#v4eAAjKpV?CVEvQ)#1*u>XgM~J*PUv zh3lw>%KiJWDu#)_!21;zkg99HUn#x9XVIFJ_3=rd*8$5d=-g#Yg}8o9WJC(C!+|er zK(HHd2U!fZLUygNxG+=CS-NeQdS*#`9$uhf$k3^CBCh3SKnYESqjv(tA1kOaxp`{i zRCB0#`u^E_XPa-d&d;=`KiZkTx;1@udwPxyibJCFRIu^?=?@!*6f~KQz|?JxA>Jgo z3dC?1nRtQvTf)X+;XiakRJhlT;muJUGq1V33Dh|NKTulj3@)bw7DH9~~WysSLgqlkxlOb;KhU_7r(WCX{+so}&=h~xsYfyhUH1=R*yl&sN z+moa{GIvXU@XCohpWfoF!FL*RP<9hzK^<8wA|3~kB_aMnLTuNpSVE5x@^MAC z!X1`?^O<1F8VdY^{F%6&fe;<=20*N$r;-$T7kAX7kyvOcjIzM&xZp1F05OZgzd_Ko pFv4phj6k5r<}is2gLeRl>*E~;@tce>evDGTD@h!Cia-QDe*roYls*6e literal 0 HcmV?d00001 diff --git a/testbed/tests/tests_backend/widgets/base.py b/testbed/tests/tests_backend/widgets/base.py new file mode 100644 index 0000000000..a6c2b8dd56 --- /dev/null +++ b/testbed/tests/tests_backend/widgets/base.py @@ -0,0 +1 @@ +# SimpleProbe, same with BaseProbe, maybe don't implement until later. \ No newline at end of file diff --git a/testbed/tests/tests_backend/widgets/button.py b/testbed/tests/tests_backend/widgets/button.py new file mode 100644 index 0000000000..95f848ffaa --- /dev/null +++ b/testbed/tests/tests_backend/widgets/button.py @@ -0,0 +1,39 @@ +from ..page_singleton import BackgroundPage + +class ButtonProbe: + def __init__(self, widget): + object.__setattr__(self, "id", widget.id) + object.__setattr__(self, "dom_id", f"toga_{widget.id}") + + def __getattr__(self, name): + page = BackgroundPage.get() + + match name: + case "text": + # was inner_text, but it trims leading/trailing spaces and removes only whitespace. + return page.run_coro(lambda page: page.locator(f"#{self.dom_id}").text_content()) + case "height": + box = page.run_coro(lambda page: page.locator(f"#{self.dom_id}").bounding_box()) + return None if box is None else box["height"] + + return "No match" + #raise AttributeError(name) + + """ ALTERNATIVE METHOD - Keep just in case + 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) + """ + + + + diff --git a/testbed/tests/tests_backend/widgets/label.py b/testbed/tests/tests_backend/widgets/label.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/widgets/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc b/testbed/tests/widgets/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000000000000000000000000000000000000..687b002d1cc8b76d29510c88417bffb71350bcd7 GIT binary patch literal 869 zcmZ8f&1(};5TCcZ*=)9nm4Mb(TD(NbC7!KC#3~38@uG*YudE$BkXrmhJc$*{qo5!Ky;X8iPtMyURUCLT^P8DB`*t32n0%}M8+Alqhdz#%YoL>ikR$I0==UXr~p+r&QH4iV_an$ z_9nPf&PcQLU7-6-!Yf^xm^~KR6oGat8a!apAakaaM42oO21ZZOX#+uuIc$i`f3hhh zqPCEnbjcgCN5*JV&gC4;Ne&<>P2K!&jgl+-$}6%%2;|bg)fw!|DQQZAujZ_KzRPIX zlzG{S!i0tiQ?MHopKg};OkjDTnR=lcrCd!t7YF1sk?)$5YA)N+fR~DyW5MH+kl#;M zmyL%pWwEhp`F=kzZbwc(!0xeuE9o*T2)wY1?gn)*c8^*t6anALQs#Bn6T_x7wHP&1 zYMTj-6U-XI+SDye5&1~OR_nN}}jRl!}TSv>Iys&DXf2w&=ppX!bG^~SJ$q}O*J z?cP1m8^hJZg~eg}K$$zLo_o3Wp}IJ{bEwa5wGS7t?xQmID;BGGe(gGGR=H;PJs(-b zyn3^jnWt#p>}AE*tr+J`yeMojiZdjXS58B`JdNpiS(KX=O>}l pdbZJE9gO^1k}R@$)VDUQhI493Q~lkLRnaLGt+eAZZ^zJOd~1e z;7t#`HBbbPN)LLhc<>MKl9EHqP*0w`3AuUl?PfRi!MyjqAK#ldkNIJm7ZAwW@bh*J zBlIhFGSN~nz6xL;QABZoLcEDFqS_m@sdaVB_#GEnSebsWd z>FGV~08t(C=@jR4Z6M27SGlJ=+UdS7N+zVJB--)Z+Dq1?m5xvA45CSq$E>_EWVglC ztm8D@kU5UJ$XOV@VxqmZm7nZV7(gk3 z{1vMY?Pr2q=&&dA(e{_XM!DIJqi zzj{jMPDt^X6bCOx z2P-2|{?^tW6XKU4%`bscv&)m+tne$~#-B)-p#JC> O7&-6`moN%jLfmS*qnUHJ#Lk7IFp4zjYubZj+o6I0ftgF2RCWa#tdi<&v0P zSt7jz62}J_F%ShUU=R%H*aQV_VZjWP2f4^{h|JTf{?#qLp-s7cvJ`CCeevbO;VzM9c#vG zF-n?JDM3*ZGv#Kol=SJisWelilut`$x|u0ud^%xfo4Hc1nJ?ujG4i^6joc1QlnQ$C z8YvBRsH%L4yz?scGCy5(oR;-7)~XtxMw`nWkR~p^ap~d(HRj1$yUkk5UUIIn>?|^) z7U=XV9K#3jfAj+=-Xs*p;ZR=!g|L2s{64-!ReGLD!2OgwLclkj*Iq8aV;jsazowby zVzc~utG3uQEXOW`OY=-?HXGJFkPAi)cyAdRvyfqGoh8x^w|0yPjUm3H!ZHR;6G3k&s^8jk}~0@?ElkMs__t$w>;9 zD>;gwZwLK)2{*Ay?SneiV@^g$7jPK69ufY_#2$AFLbxy_sDACpg1#M09*g#PPDuOp zlRYf(N00B>qtWm7$R(jCQRDzm`bh8~9??1oD+#T-v}aGFkr))a{tWFkc2D#G&!wk$ zo(MfptS7oKUp?6w74s44J0^Jj+Lc9LQD3FHyi9viFX2EQ^T&+~>B6|+^=myTq`HFQ zUh-SAS8)|L`R)WDQf|ujBAy-SYZH_Xm^rGavCmxWcCZ@$>$6A+W0Bc&7DcmoKo+@S zd|4DZD2GENWKBlTuE+gsNb5O0@1`5{8Rdoo^G65nPtRh83(qWWRLsRw;}#+zZiPr* z4vO*r&kT#D7^!=TKGe%NF9?<(?{23f8WaKWBT{pT(FDf_xy&-3r(U9-f#-Mcwq`sR=LM?i|Bn z8jBNHoW$bvd5@+U9syPQO>A~nrQM@w@VGDtMBkUdU~!gv2PQqmSZ>2P+R)WF8wNF~ z^f=oGR5$>v%;0BvhOOx1BdS_I(-U@GgzGM79yTcL!dng0ynJ| zJeezCc&_y%3_rsIFvB(AWzV%ca2aTraAB}L*=*G`)ArK9^{)vU^Y&NldTY_tE6ixK z%DmaCf=`y#G`v*B*5(Xkd-O0r8yw+5|CRB#~ZGI z4bxXwYc<<&Y4HcF7W}zqxNS1rZ`n)eU!%O**^5c32+89>*SCF0l9qNnG12@Ybm)|@8aAytwxppKJ z3vwH{eHB~`sz0$qq#yyg@wh>NfYQH=jIBDqJbUxik6*oYa%*Jbx5JwwlPk)D!stg= zu3yfa~Mr?@T1n66)j3I z@Je^>(q`fCeFcB_c$+uEauc|VytUOQw-Fm+I05?BE`>-zMnRz93$X28cQ=%oJBOfH zzkJKOb7=eE4A3j?#=)7*!pwaIe}4`bycL$4zy@~^vDGKH5gcMT;cE|(>kz*{GbipU zGq?j9+-3s>7Zl7C!qSCh~ZNulgyzHekt4 zF#|p?IIz@1mX{28>APn$af1|aSXpiFueZr1*yz5Tl%7Dyu!`_4|i?*G!#2= xO6fyFu917><-d}1|56Bj;URf(M<%h8U`?Akp}o#H7?5y@JYH95%W6DWy57 Yc15f}hcE(hF^KVznURsPh#ANN09R2!hyVZp literal 0 HcmV?d00001 diff --git a/testbed/tests/widgets/conftest.py b/testbed/tests/widgets/conftest.py new file mode 100644 index 0000000000..0c9c0bd95d --- /dev/null +++ b/testbed/tests/widgets/conftest.py @@ -0,0 +1,22 @@ +import pytest +#import toga +from probe import get_probe +#from .probe import get_probe +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. +@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 = BoxProxy(children=[widget]) + main_window.content = box + probe = get_probe(widget) + yield probe + main_window.content = old_content + \ No newline at end of file diff --git a/testbed/tests/widgets/probe.py b/testbed/tests/widgets/probe.py new file mode 100644 index 0000000000..e6567cb4b0 --- /dev/null +++ b/testbed/tests/widgets/probe.py @@ -0,0 +1,7 @@ +from importlib import import_module + +def get_probe(widget): + name = type(widget).__name__ # e.g. "ButtonProxy" + base = name.removesuffix("Proxy") # -> "Button" + module = import_module(f"tests.tests_backend.widgets.{base.lower()}") # -> tests/tests_backend/widgets/button.py + return getattr(module, f"{base}Probe")(widget) diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py new file mode 100644 index 0000000000..a616b5727c --- /dev/null +++ b/testbed/tests/widgets/properties.py @@ -0,0 +1 @@ +# Maybe don't implement until got more widget-specific tests complete. \ No newline at end of file diff --git a/testbed/tests/widgets/test_button.py b/testbed/tests/widgets/test_button.py new file mode 100644 index 0000000000..d6d6e6e83e --- /dev/null +++ b/testbed/tests/widgets/test_button.py @@ -0,0 +1,48 @@ +# Handled differently in real testing with get_module() +from tests.tests_backend.widgets.button import ButtonProbe +from tests.tests_backend.proxies.button_proxy import ButtonProxy +#from ..tests_backend.widgets.button import ButtonProbe +#from ..tests_backend.proxies.button_proxy import ButtonProxy + +from tests.data import TEXTS + +from pytest import approx, fixture + +@fixture +async def widget(): + return ButtonProxy() + +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 + + # no-op + #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_text_change(widget, probe): + initial_height = probe.height + + widget.text = "new text" + + assert isinstance(widget.text, str) + expected = str("new text").split("\n")[0] + + assert widget.text == expected + assert probe.text == expected + + assert probe.height == approx(initial_height, abs=1) +""" \ No newline at end of file diff --git a/testbed/tests/widgets/test_label.py b/testbed/tests/widgets/test_label.py new file mode 100644 index 0000000000..e69de29bb2 From 2593c4094836952caf5b8f3e482e43fdaaa39674 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 29 Aug 2025 10:04:07 +0800 Subject: [PATCH 02/37] Purge pyc files from repo. --- .../__pycache__/__init__.cpython-312.pyc | Bin 193 -> 0 bytes .../tests/__pycache__/__init__.cpython-312.pyc | Bin 207 -> 0 bytes .../conftest.cpython-312-pytest-8.3.5.pyc | Bin 845 -> 0 bytes .../tests/__pycache__/data.cpython-312.pyc | Bin 670 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 211 -> 0 bytes .../__pycache__/page_singleton.cpython-312.pyc | Bin 7080 -> 0 bytes .../proxies/__pycache__/__init__.cpython-312.pyc | Bin 219 -> 0 bytes .../__pycache__/app_proxy.cpython-312.pyc | Bin 705 -> 0 bytes .../__pycache__/box_proxy.cpython-312.pyc | Bin 1949 -> 0 bytes .../__pycache__/button_proxy.cpython-312.pyc | Bin 2364 -> 0 bytes .../main_window_proxy.cpython-312.pyc | Bin 1550 -> 0 bytes .../widgets/__pycache__/__init__.cpython-312.pyc | Bin 219 -> 0 bytes .../widgets/__pycache__/button.cpython-312.pyc | Bin 1785 -> 0 bytes .../conftest.cpython-312-pytest-8.3.5.pyc | Bin 869 -> 0 bytes .../widgets/__pycache__/probe.cpython-312.pyc | Bin 737 -> 0 bytes .../test_button.cpython-312-pytest-8.3.5.pyc | Bin 4795 -> 0 bytes .../test_label.cpython-312-pytest-8.3.5.pyc | Bin 217 -> 0 bytes 17 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 testbed/toga_web_testing/__pycache__/__init__.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/__pycache__/__init__.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/__pycache__/data.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/__pycache__/__init__.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/__pycache__/page_singleton.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/__init__.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/app_proxy.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/box_proxy.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/button_proxy.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/main_window_proxy.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/widgets/__pycache__/__init__.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/tests_backend/widgets/__pycache__/button.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/widgets/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/widgets/__pycache__/probe.cpython-312.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/widgets/__pycache__/test_button.cpython-312-pytest-8.3.5.pyc delete mode 100644 testbed/toga_web_testing/testbed/tests/widgets/__pycache__/test_label.cpython-312-pytest-8.3.5.pyc diff --git a/testbed/toga_web_testing/__pycache__/__init__.cpython-312.pyc b/testbed/toga_web_testing/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 387f40952a23e334a432da5c4c17f945f3bc4a8e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 193 zcmX@j%ge<81Q|WO86f&Gh(HIQS%4zb87dhx8U0o=6fpsLpFwJVB{*Bfgche36~{Oy z=H!&-#<=7sm*%GCl@!MW6y>KECFbU4=A{FfS*giDF`v}LqCAj5PGV(wQD%BZNla2| zYI$N&YJ7QWQhZ5jaS2d1CO$qhFS8^*Uaz3?7Kcr4eoARhs$CH)&_YHaE(S3^GBYwV I7BK@^0N$52N&o-= diff --git a/testbed/toga_web_testing/testbed/tests/__pycache__/__init__.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 6aaa822e385c0e3481685754d30233e96e752282..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207 zcmX@j%ge<81a24BXMpI(AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdmFH{~6Iz^FR2<`+ zn3GeQ8{?9nT$-DjS5h1kP?Voul$e{FnU@Y^W~C+r#e7l|i}FAMIf<3!MVaXtB{4~< zspW}9sqy8hN%1AA#U()57!aM5ngXVaW8&j8^D;}~8AQ(dc0g0eXl+|M0*9gQ$w?gI_vug%gphs40cWnY1P;+o=+3K$ijE|K|WRen? z=WscP(5?Pgh4oKGtt4&xO4c7<$&MF9*!x-bjHRcgusG#BOp`Vd74D}Q^%q3SXsG3S zvlgl`)sJ7flCFJOimekTVz&T~!~cMiNL8ph6})E~w)$+?4!edfh+nDh}2 zsE@HH)MJSZL|$32uz!*}JxU9YQMaHyH>W(utRroY2Fh~BPm^OA>13tQy)0u5WVZ0* zbyOqxX+r(>+ntGVht)9Puf|44Vztr+f-$^=0ov zVw*`S)?6y-@f)Z{Fh%y}yS+Di6Jta1x9h_04Z{dG;iCR9E8Ug1?q;Rh#N&8pIX*;( zQM`woinUcpSj$)yi{)|GW5S$6aaRVo+w=SvGzmHxK5IyM2d~AIxMUTaN?6C?X+C6B zZ7?5O{?TeawTiHJ<1`pX^Z{GPgxrxoMK#q4A-`-OqU&~9E!Z>qFBxT)y=04-<8CIA2c diff --git a/testbed/toga_web_testing/testbed/tests/__pycache__/data.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/__pycache__/data.cpython-312.pyc deleted file mode 100644 index ee7036142b6e45969cb4a46e14ba147224aa9860..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 670 zcmZ8ey^9k;6n`^2d)a)QXAmu2%LTbVKo6VqI1n!iMv$;D%x1d@R5Bdmq1fZ)ZNY+YK=G>;1dYHw5tA1{-Tt zWVL6=8DKaB4h!Ju9Iq>V;Fvp{aF=iK8uxgeH+U0+fb&W^Z1z#!3fhAzE}fZE+NxvR znd$B$4t>ng0{Tvl14nyD`Ge?K4AQ}-w~~Nk$cmJ@90j;L-#*Iyo=~a37QBT3X`)2@ zR9UJ2_jvzS_*e<4!hIIUSrXoz4zff{QWZXw)1hQZGM)?#wc}KHUobhbiJ0XxIUbJE zFcM(#yGwN@R!aLXV02QbF1xc~qF diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/__pycache__/__init__.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/tests_backend/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 9d5c9e197d1dab6193aee9ba1391d772a0d22931..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 211 zcmX@j%ge<81dF XD`EvYff0y{L5z>gjEsy$%s>_Zxfea; diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/__pycache__/page_singleton.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/tests_backend/__pycache__/page_singleton.cpython-312.pyc deleted file mode 100644 index 5415c645aa5c322f4c4d44eb8baf30052fb4199d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7080 zcmd^EU2qfE6~0%yTCFYlPqq;q}5q?kYo{qpTTVkhJ;STGZtVdfqLW|D|Rq+!xy zZDG2+HrVh_r{X5Qz=xtIQoj$dyd7hZ`qHsAxIG_t+#JSCMTg64_Iv zr&zQCwTm23hiC&@BJw~>#Uh|(VlmKi(GIj?oE)P)&V9t^&`KW;1dsO0(V>X=c%WD6 z4$_?IIp7)i=6(U>6*@^qNhqh;WH%9tE#}WNt05n&QqAop6LggPpoBorE15Y)iIANQ zwi*7+ZM~bkeWZbGAPS9HC^-PL-o4*3<^YM&DcbF0HLDPg29HA4A5m1zstgTEvSt&6 zh!hh9pG9MWVMQwvgkU(JD1xBi52+vf;>6DOgHI`vtQ_1K2#1FT4(^HuhX$mGsvLY= zj`qrdfr0)=FR&pg2z7gY!?BO3gEJ?9|ED15`u%Jqc3atk*KP-uQt{7A}3}+xk z#Djxy0)hg)!jc+|v-(2nx6- zIuy!*nFLLMV+0POFsc~oC`k@XRl_DTx!c{GHklWMgjwp#)$!GAR6)6H5*h0R8^L%K%7>j_Q_H} z1o&yz&SwGV8Yf`Iu&zMSL5ziBRJ+I7GsQQScqXIgqNydTpky~I zUG-_#s-$bxW%Vb+Q^Qx|i6@`F?s^7#IA(aqS;vGU&DSLPn(KTmmhBVvG`}>-Fa3nC zk#WXguE9c}Ac}R{oC~%>W)0CF%7O<*Wzs0^rK6S~(YLu5uty>AW#&B2j4~m_e_k?= zGow>Jt7g-$l>Vr;6gFK5M!{5r9g#AduLO=ra6Hnpfv{W$vNTSXl%cRX#mbFXw1gCm zg~L}g=E#t$;F{|p8OmwHVM0i`!@y+fYoWJ-w;~)%X?41^C0W{XIS#uWpNe1gCR%r0 zRX=Xs^`7{f&~zwO`ouVc<|jT8e||KZSO_ z_;tP+&1dmMaklpR7r&qKtiR50Sk!Qxug{hj+1c#H4b$PlorG_$8vtY&fufwN-Q3;~ z!YePCZCNE`4JFJ4R$=HAvc!epBGR`kCMXKbl64SZmICG?^c8R^APsLDJ%O|W4n?TK zY+uN~Vh6H!2}nZ?&P#<{2Qh2j@>i#WyO0!Do>(YCER;Fl`ZHjaQJ**1aEMP`(d*wy>lq@#;QR z9c*uHh3F>`?u#mF`{wnV*Y}Tu>lyc~4n{@ESHEq0eXKtsMq@1)m>=W2YgPmT`s4-IHsI4~3m_G!FuSdPZP8fkVk zaKUIqm5!-Uo#n6^gB_HC8QU9GqguInPYP=PfCPSy#^a4Q1lG^6m+W4Q<2WIp$8$xU z$AZ$J+8>R`hzgAX!|Agaeh-z5D9-S7bUulHN^e@7APu>w99tyEWzQO4oKlk8fg+ zb+4~WdpnZej+A#>x^|mh?P<7l{NnMnXH(L%DdlNPyW3FSr_Lpl&F7jYThFy7yjxPv z_Ozot;b_lPr=9DP&UKf?lyhU+u`%J;n5|!#bgoP^?MXZ$q@0J+jzbB@p}*FwxU}}- z+8?%n+^$)klvJGUn&>+F*u-O>IO=9Sq^AAzncA-Tnc8jhGw!zePaW;>ZG`X8=bu4X z=)Os4k?+QW5FCAbmVwIK5H>E7_NDR;Y}g69dnG-!tkXp<(N3Oy?E&DXowU==PFFS| zzlC;|u+#0xzh6%~%h>lt>0RLeb?W|@$EU!a;-7*k< z4SKrBHM>qrbP9c-rb4HcI<3~Jt7tdFT&s6?ZfCA-vF_%`we41@`G{eF|A?#I?PWgl zG9Yu2(~l-t_eg&ajBy!(5(1x}9waB>p(fY!!^a@#oAqcGM%*hf$eK6CxW0mx#cv-c zp}cq|-OFqT6Xb%g2wy<-K5&)3s>^62);A| z2Sh=&HUk6Qb&Xf$s|)JlcI?{;MAwv=O4nq^WXJiA8TZ-?RhMcm)}-BQlkT;bpG>&h ze%ko4yDeSWc8eiZp2?1vI_768+vaDnG>@asb$qGp14$<;{62b~`b)W&8Q>;Y_^X(! z)L+iM%K-N)2h>gT)bHe`i#X)VDO3Z{kgwwWO}6P8>aTN5FS8-i!G;1-(&gbEq`z?@HSObR>%>CnVfp{NH!V~{eTDf*}pNsh11L!lW2w`|pKHD!BCrsq!F zK_v4AG+oOkM-<-nl6C&H%MZ9*+Ault(#ZUb%a6ER z+Axnp&W(IIhqydh;cuWXQ$NqW!T@)n#$U?(jQWeYpEJO{S-j;g zYGyn{+@Wk;DC;RCPXqB;^I^RKn;t;&43IkjTx#Mp^UhKCm9syJBJlpYt!wtZ@O-~- z9MAdPd2bxPO7Kwyd?OVEh_r=)s5lfx-XRFz9SVev78ye=xdzEMkRS&2cY27_@gE2S zlG+y)WeneBj5lP|LwO?-jK+0e)OZ~F78bt^L@UYs^C2jO!|*Q&Dq|?A$MFy8_E1JO z)Ct~Z$^jtrr^t+>e5`ZEQ8WI?+3tz%l%sa6^D~yUx@R4PtN4e7TC3+6;AWA#$??`@ zH>)hx+MB$^`VhUzTcKQ9VO@T+$zt7nljp3p|19UAMSr-{=Q7U~f2&rt@K3E4D`EvYg%OC0L5z>gjEsy$%s>_Z8$>~I diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/app_proxy.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/app_proxy.cpython-312.pyc deleted file mode 100644 index a39b834c26db816fbe97f3d70615ce4461834e24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 705 zcmYjP&1=*^6n~SCHMXuxt>{fs@DhZ*c_|`DMK5*{M1&9+W;5e9nlERvr0K~+{Tudg zC_VJA=&6^27Y%sQi#Ig~!IN+DQT9Q8?{j|dy~$jR#~Xn1?DFj39QAkY3}W?0b03Wr zaNs0{g!qJj0ndRO$H2`~qpR|b%JS@=1icedPxe`qzKK$vl`my>ScN@RtNIhrxah7x zfKNF11}DBLEX{h_SNnEldRFZ`$@7-w=)r!JMhS~umgjD+iFe&BjVo6i=9v<1f?a9Z zww;E0ltFY$6TjvT!U~G3+|cS3cw{DZiAQQniTJJ3HSWJpd%MAFC8P@WSR5~t;As{v z6Ok4wz?l}3B}tSn(0nICjJ*&{raBO_s+7^%_2$}YN4JLynXW}`99L~3e1sp9d!HxnrQpv)f7P$JNnoY3r&*?L6w{dn~sxp?`w9h49NHgnWbP NU&|spe*mF9^B=@KxhVhu diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/box_proxy.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/box_proxy.cpython-312.pyc deleted file mode 100644 index 11a42ecca7ac2c88ab57696d5bae6e885a75fed8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1949 zcma)7O>7%Q6rR~1d*e8E;v`j*wy8sql2uiMREa}HB4`6#kf}g$a2<`-I}^vr+8buq zIJP!Y4?#*$rN99t5<&_mROP_6$8rLRi=%*2mLe5#;%4FlQct|uwVfsn5@YGjyq|gV z_Ip46X<#6RV7=Y^ZfQzE=ug3P7x2MqzYNt9Z0SXtr(L^fWI!=vE|o0GS!MQaCX-pv zGg-3}YT!_OoQR}Nf$Sg;E#p9bige%h5b0?>%*Q-Dj~0dzT9@FSdg@BRc+!5y9;5rC z3aZ2J0nX~OpCTq_hRXdOYDpjI4aE&5tFbYBfljtDfJPCxtf_Rai< z4&zS#wqaS-a(>=6t7TSkojd?gcUYLcL4)A+sKMfhZLk|PufV2iJHum>!enqMdmtpJ4TrjT>!%l7vs#S zTJDJe)X%QYm^Nj4dUh^dD^;jn%ZOSdW|@WxUFISWz6^f-DEq{)$g%^%ej!v=VRHD2 z@Nf!du7S33>7?>r=qTt+qybTGeFLo+8XVd7w!G%$ z*@y07a=xj}hmb!HX0P-<184smau{VW7DT$vtgm2ZLMqPJ$X~1LvL`J^&s+kQDqe5S zVi+a5nr#f7&B}D+;`BlW#>mWz8hLs_=i}f>R|3HKB{&Ba&o7740P#qeVSYtuCxAF2 zR2V$zpuV~It7yb~z{6YOw(};Cvq(QpT-{T?jeZl|tv6@hcy!^1vG2zo_5XOI8GkRt zKLSbEj&H@Aqpv+we$i$F@VWSSJthWB#V9lA!WbcCn^rAxjuY~6)v!7mXjE#OgzzNX z@%v57aGWx8muwiOj?4K)5C^3ag#7@84~RbHBCP=t#pk>VWCQ)GTyIMeHQgFSk)f6( ztHZnIF@mivcLm3>sQMOer&M(=bSC_Wp`H+xN*tJ_7IW=N2yyU;X!w(deeLz|W9GOH a3emt$3P?-F82^s0{i96axnl$*xbrXZy}+UX diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/button_proxy.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__pycache__/button_proxy.cpython-312.pyc deleted file mode 100644 index 4b4d48083a448ee1bd32c730d54ec93e3572165d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2364 zcma)8U1$_n6u$GfKglK=btIZryZ#Z!vX<ri*t@qe|7??=ODYx zrr8-PJaJ{(~C<0{Y5T)3rDxL^O zC(V)@(w-Efp|v|BX+o;c$}T>`9}Us<3ker)BG?0Mu%m4YHAxXE@t^UB9k%R*921lw?D`4`ta9?n6m7l>6jCWH!=}io;>N?o5?NBz(3&fP9fD zj;~T5LQ17eHN9%m0&|_Kb@5udE~Um(FW)ez}pwm8{5xT=0U7ymm(#*R0y{CMf7 zLr>HvZtL=wkL#^dnYANV?;gDC+&g}!zP5e;n!#GrH&zX6F1j93;)(Ua7zmYY-@l+O z>{-k;)n=rbUmjiF-AYc($-l%Bw-dJ#t(_;9<)^W6j%>;I(iBPQlbM6^)?|h@3SF&3 zXgJy)KoJp%@<5)I8sh7S4n&edid-U^JXIgz%&Ax|aA7M+M(fh(wKPWxYqHM*u7d7U zith<_5n#R-MGD0M5Ds1pGvwz7n_QbN&@Z;+sbe^T=RO6oHBajilGGN}N5cEtpu#L5`MNI$ zAT0JEC{5L=&8qFn0uN ziiLVY*T1S5R$$>n5Zc3H%>;C zv%={_-0}@ zAqy22jG%~k*n@cUA`5z5PkI!*q%6{PXhG0RZ`j}9ATX|uf7}oyLjJ-$CiLJ1XI;t6#@AXKE1D|%905{O9NBvjreRM{2N zjJn*<8Tos-GK6^XvSokR;z8G^*Q^%1C!}WeaKbz zsZf`gOvP=oJB_X@OlcS9Q&XKv^D%wdP-5+E%kkfHd>ZtU4F|6#lOAnYk#1YQ-eS?k zkbxff5%Z&pzLqMw*9{}R$#hH4nyuK_el`#xK#fd(0I@@&nXjFlBa4JN>wHU~!j?D` z+RB`Bs#0+%W+-k+L+R#1l+U8F{Ojb%)>bp`(H!M6eRtZ=mOPY(vN_vtC|@UrR2w|a zS=e+Qze7Kp%2eJV+WKO`f635IwKZFrtQ(xVdOYs5K91KoM#_f zuGtPbZWiZF(`P-?j75jSMaZ~KEO&qfv5ZCITr=1-9U3bgWI~8ZI*x~5tX*on8!{d? z)-2cUdW|cA-Syyd!UkMki(8)O_$^S|%m&+A%;G*8T&v&XPHQ7-G#TqzoS8k=G_hgu zHgIk-n#^G;%_gvu`Lux@I4o?;p6heW6 z86O4GCx%=bJpVYd%y`rX7ExeB`2K}~c;N;=1_cc{VF}0Yh8IyBPpykE#mA5b3EEsk zUkOBr-69$IGMdUD9t-~ojDHctf8bwQ-E)3+e{%QMe|>4J)!;7F!k2~53geYm_T=By zRlWjQH6tIZz*Wm*K-LA*^a9#-Q7@S0M_tREdVtR~u)(ZCuN}ste$rN)gWn@!{CkpA zOZ!DMISM>RcoD=l`9pr=Po;QYRF##9L~@IVs2!F`uK4NtC+qh`NvWc}oM>q+tWblu zvxu+841vWiivm9d^CUQvmzHeA&*Bo#lXMWPucgPRavf~=7li0MlmtPzOP;$+&ix~w L5ULLdh$QV_x0G|< diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/__pycache__/__init__.cpython-312.pyc b/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 411559c56259829227057939e1344a444a654aa1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 219 zcmX@j%ge<81iQL=GeGoX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!s&cl92`x@7DvohZ z%*iRujd96OF3nBND=Cf%D9TSSO3cm8%u5F{vr?0RVm_&fMR_2BoW#oVqRjM+l9;5_ z)bhll)cEq$r1+B5;u4^243HfIVi(6JB_?O5=B31xXQrg50u6|d&&7%Q6rR~#yQ|i*lM<39ZG_tf#L6X-qk>dgT+|}L0l7g@FP7WMu6OF#-Jdc$ zCUJ122t}e?Pkzd7^hW+>jy>PwA+=^q)=tWxSMhYg}{pcJ4rH&}dVH4#f6R)D2EQ02gs;nvg zp$iqxU1x^51}$_?30$KfW%zZz3l!ZiYU$fu?^uL%y-Yv8$sKrZd84hkdjm4ge(pB zVKLQP6+QLd1hIl1sqr0kd`lg_bNcS^-A}%+ep}sE)BUSQez@FtYxB&;nMSIW%KWUJ z?|>tE4p$K`!1X&&J_l;>9#v4eAAjKpV?CVEvQ)#1*u>XgM~J*PUv zh3lw>%KiJWDu#)_!21;zkg99HUn#x9XVIFJ_3=rd*8$5d=-g#Yg}8o9WJC(C!+|er zK(HHd2U!fZLUygNxG+=CS-NeQdS*#`9$uhf$k3^CBCh3SKnYESqjv(tA1kOaxp`{i zRCB0#`u^E_XPa-d&d;=`KiZkTx;1@udwPxyibJCFRIu^?=?@!*6f~KQz|?JxA>Jgo z3dC?1nRtQvTf)X+;XiakRJhlT;muJUGq1V33Dh|NKTulj3@)bw7DH9~~WysSLgqlkxlOb;KhU_7r(WCX{+so}&=h~xsYfyhUH1=R*yl&sN z+moa{GIvXU@XCohpWfoF!FL*RP<9hzK^<8wA|3~kB_aMnLTuNpSVE5x@^MAC z!X1`?^O<1F8VdY^{F%6&fe;<=20*N$r;-$T7kAX7kyvOcjIzM&xZp1F05OZgzd_Ko pFv4phj6k5r<}is2gLeRl>*E~;@tce>evDGTD@h!Cia-QDe*roYls*6e diff --git a/testbed/toga_web_testing/testbed/tests/widgets/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc b/testbed/toga_web_testing/testbed/tests/widgets/__pycache__/conftest.cpython-312-pytest-8.3.5.pyc deleted file mode 100644 index 687b002d1cc8b76d29510c88417bffb71350bcd7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 869 zcmZ8f&1(};5TCcZ*=)9nm4Mb(TD(NbC7!KC#3~38@uG*YudE$BkXrmhJc$*{qo5!Ky;X8iPtMyURUCLT^P8DB`*t32n0%}M8+Alqhdz#%YoL>ikR$I0==UXr~p+r&QH4iV_an$ z_9nPf&PcQLU7-6-!Yf^xm^~KR6oGat8a!apAakaaM42oO21ZZOX#+uuIc$i`f3hhh zqPCEnbjcgCN5*JV&gC4;Ne&<>P2K!&jgl+-$}6%%2;|bg)fw!|DQQZAujZ_KzRPIX zlzG{S!i0tiQ?MHopKg};OkjDTnR=lcrCd!t7YF1sk?)$5YA)N+fR~DyW5MH+kl#;M zmyL%pWwEhp`F=kzZbwc(!0xeuE9o*T2)wY1?gn)*c8^*t6anALQs#Bn6T_x7wHP&1 zYMTj-6U-XI+SDye5&1~OR_nN}}jRl!}TSv>Iys&DXf2w&=ppX!bG^~SJ$q}O*J z?cP1m8^hJZg~eg}K$$zLo_o3Wp}IJ{bEwa5wGS7t?xQmID;BGGe(gGGR=H;PJs(-b zyn3^jnWt#p>}AE*tr+J`yeMojiZdjXS58B`JdNpiS(KX=O>}l pdbZJE9gO^1k}R@$)VDUQhI493Q~lkLRnaLGt+eAZZ^zJOd~1e z;7t#`HBbbPN)LLhc<>MKl9EHqP*0w`3AuUl?PfRi!MyjqAK#ldkNIJm7ZAwW@bh*J zBlIhFGSN~nz6xL;QABZoLcEDFqS_m@sdaVB_#GEnSebsWd z>FGV~08t(C=@jR4Z6M27SGlJ=+UdS7N+zVJB--)Z+Dq1?m5xvA45CSq$E>_EWVglC ztm8D@kU5UJ$XOV@VxqmZm7nZV7(gk3 z{1vMY?Pr2q=&&dA(e{_XM!DIJqi zzj{jMPDt^X6bCOx z2P-2|{?^tW6XKU4%`bscv&)m+tne$~#-B)-p#JC> O7&-6`moN%jLfmS*qnUHJ#Lk7IFp4zjYubZj+o6I0ftgF2RCWa#tdi<&v0P zSt7jz62}J_F%ShUU=R%H*aQV_VZjWP2f4^{h|JTf{?#qLp-s7cvJ`CCeevbO;VzM9c#vG zF-n?JDM3*ZGv#Kol=SJisWelilut`$x|u0ud^%xfo4Hc1nJ?ujG4i^6joc1QlnQ$C z8YvBRsH%L4yz?scGCy5(oR;-7)~XtxMw`nWkR~p^ap~d(HRj1$yUkk5UUIIn>?|^) z7U=XV9K#3jfAj+=-Xs*p;ZR=!g|L2s{64-!ReGLD!2OgwLclkj*Iq8aV;jsazowby zVzc~utG3uQEXOW`OY=-?HXGJFkPAi)cyAdRvyfqGoh8x^w|0yPjUm3H!ZHR;6G3k&s^8jk}~0@?ElkMs__t$w>;9 zD>;gwZwLK)2{*Ay?SneiV@^g$7jPK69ufY_#2$AFLbxy_sDACpg1#M09*g#PPDuOp zlRYf(N00B>qtWm7$R(jCQRDzm`bh8~9??1oD+#T-v}aGFkr))a{tWFkc2D#G&!wk$ zo(MfptS7oKUp?6w74s44J0^Jj+Lc9LQD3FHyi9viFX2EQ^T&+~>B6|+^=myTq`HFQ zUh-SAS8)|L`R)WDQf|ujBAy-SYZH_Xm^rGavCmxWcCZ@$>$6A+W0Bc&7DcmoKo+@S zd|4DZD2GENWKBlTuE+gsNb5O0@1`5{8Rdoo^G65nPtRh83(qWWRLsRw;}#+zZiPr* z4vO*r&kT#D7^!=TKGe%NF9?<(?{23f8WaKWBT{pT(FDf_xy&-3r(U9-f#-Mcwq`sR=LM?i|Bn z8jBNHoW$bvd5@+U9syPQO>A~nrQM@w@VGDtMBkUdU~!gv2PQqmSZ>2P+R)WF8wNF~ z^f=oGR5$>v%;0BvhOOx1BdS_I(-U@GgzGM79yTcL!dng0ynJ| zJeezCc&_y%3_rsIFvB(AWzV%ca2aTraAB}L*=*G`)ArK9^{)vU^Y&NldTY_tE6ixK z%DmaCf=`y#G`v*B*5(Xkd-O0r8yw+5|CRB#~ZGI z4bxXwYc<<&Y4HcF7W}zqxNS1rZ`n)eU!%O**^5c32+89>*SCF0l9qNnG12@Ybm)|@8aAytwxppKJ z3vwH{eHB~`sz0$qq#yyg@wh>NfYQH=jIBDqJbUxik6*oYa%*Jbx5JwwlPk)D!stg= zu3yfa~Mr?@T1n66)j3I z@Je^>(q`fCeFcB_c$+uEauc|VytUOQw-Fm+I05?BE`>-zMnRz93$X28cQ=%oJBOfH zzkJKOb7=eE4A3j?#=)7*!pwaIe}4`bycL$4zy@~^vDGKH5gcMT;cE|(>kz*{GbipU zGq?j9+-3s>7Zl7C!qSCh~ZNulgyzHekt4 zF#|p?IIz@1mX{28>APn$af1|aSXpiFueZr1*yz5Tl%7Dyu!`_4|i?*G!#2= xO6fyFu917><-d}1|56Bj;URf(M<%h8U`?Akp}o#H7?5y@JYH95%W6DWy57 Yc15f}hcE(hF^KVznURsPh#ANN09R2!hyVZp From 65c02f3657cb8aae5ea88677acd4c7994a3cd87b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 29 Aug 2025 10:43:28 +0800 Subject: [PATCH 03/37] Reorganize code to make better use of Briefcase. --- testbed/toga_web_testing/README.md | 16 ------ .../toga_web_testing/testbed/pyproject.toml | 7 --- .../testbed/tests/widgets/probe.py | 7 --- travertino/pyproject.toml | 4 ++ web-testbed/README.md | 15 ++++++ web-testbed/pyproject.toml | 54 +++++++++++++++++++ .../src/testbed}/__init__.py | 0 web-testbed/src/testbed/__main__.py | 4 ++ .../src/testbed}/app.py | 14 ++--- .../testbed => web-testbed}/tests/__init__.py | 0 .../testbed => web-testbed}/tests/conftest.py | 9 ++-- .../testbed => web-testbed}/tests/data.py | 2 +- .../tests/tests_backend/__init__.py | 0 .../tests/tests_backend/page_singleton.py | 17 ++++-- .../tests/tests_backend/probe.py | 2 +- .../tests/tests_backend/proxies/__init__.py | 0 .../tests/tests_backend/proxies/app_proxy.py | 4 +- .../tests/tests_backend/proxies/box_proxy.py | 12 ++--- .../tests_backend/proxies/button_proxy.py | 20 +++---- .../proxies/main_window_proxy.py | 1 + .../tests/tests_backend/widgets/__init__.py | 0 .../tests/tests_backend/widgets/base.py | 2 +- .../tests/tests_backend/widgets/button.py | 20 +++---- .../tests/tests_backend/widgets/label.py | 0 .../tests/widgets/conftest.py | 20 +++---- web-testbed/tests/widgets/probe.py | 10 ++++ .../tests/widgets/properties.py | 2 +- .../tests/widgets/test_button.py | 39 +++++++------- .../tests/widgets/test_label.py | 0 29 files changed, 177 insertions(+), 104 deletions(-) delete mode 100644 testbed/toga_web_testing/README.md delete mode 100644 testbed/toga_web_testing/testbed/pyproject.toml delete mode 100644 testbed/toga_web_testing/testbed/tests/widgets/probe.py create mode 100644 web-testbed/README.md create mode 100644 web-testbed/pyproject.toml rename {testbed/toga_web_testing => web-testbed/src/testbed}/__init__.py (100%) create mode 100644 web-testbed/src/testbed/__main__.py rename {testbed/toga_web_testing => web-testbed/src/testbed}/app.py (81%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/__init__.py (100%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/conftest.py (68%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/data.py (99%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/__init__.py (100%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/page_singleton.py (86%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/probe.py (90%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/proxies/__init__.py (100%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/proxies/app_proxy.py (78%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/proxies/box_proxy.py (74%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/proxies/button_proxy.py (83%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/proxies/main_window_proxy.py (96%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/widgets/__init__.py (100%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/widgets/base.py (90%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/widgets/button.py (73%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/tests_backend/widgets/label.py (100%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/widgets/conftest.py (53%) create mode 100644 web-testbed/tests/widgets/probe.py rename {testbed/toga_web_testing/testbed => web-testbed}/tests/widgets/properties.py (90%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/widgets/test_button.py (56%) rename {testbed/toga_web_testing/testbed => web-testbed}/tests/widgets/test_label.py (100%) diff --git a/testbed/toga_web_testing/README.md b/testbed/toga_web_testing/README.md deleted file mode 100644 index bbe735eda8..0000000000 --- a/testbed/toga_web_testing/README.md +++ /dev/null @@ -1,16 +0,0 @@ -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 repository in VSCode. -2. Ensure you have the following installed in your environment/venv (these are the versions I use): - - `playwright==1.51.0` - - `pytest==8.3.5` - - `pytest-asyncio==0.26.0` - - `pytest-playwright==0.7.0` -3. Open a Toga app in another VSCode window. -4. Copy the contents of this repository’s example `app.py` into your Toga project. -5. Update and build your Toga app for web. -6. Run your Toga app as a web app. -7. In this repository’s VSCode window: - - `cd` into the repository’s directory. - - Run: `pytest testbed/tests` diff --git a/testbed/toga_web_testing/testbed/pyproject.toml b/testbed/toga_web_testing/testbed/pyproject.toml deleted file mode 100644 index d65fc1c3e2..0000000000 --- a/testbed/toga_web_testing/testbed/pyproject.toml +++ /dev/null @@ -1,7 +0,0 @@ -# Attempted integrating async operations, but it required calls like 'widget.text' -# to need 'await' when calling, which is not the case in Toga test methods. - -# Uncomment below if using async. - -[tool.pytest.ini_options] -asyncio_mode = "auto" \ No newline at end of file diff --git a/testbed/toga_web_testing/testbed/tests/widgets/probe.py b/testbed/toga_web_testing/testbed/tests/widgets/probe.py deleted file mode 100644 index e6567cb4b0..0000000000 --- a/testbed/toga_web_testing/testbed/tests/widgets/probe.py +++ /dev/null @@ -1,7 +0,0 @@ -from importlib import import_module - -def get_probe(widget): - name = type(widget).__name__ # e.g. "ButtonProxy" - base = name.removesuffix("Proxy") # -> "Button" - module = import_module(f"tests.tests_backend.widgets.{base.lower()}") # -> tests/tests_backend/widgets/button.py - return getattr(module, f"{base}Probe")(widget) 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..29d833ad9b --- /dev/null +++ b/web-testbed/pyproject.toml @@ -0,0 +1,54 @@ +[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" + +# Attempted integrating async operations, but it required calls like 'widget.text' +# to need 'await' when calling, which is not the case in Toga test methods. + +# Uncomment below if using async. + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/testbed/toga_web_testing/__init__.py b/web-testbed/src/testbed/__init__.py similarity index 100% rename from testbed/toga_web_testing/__init__.py rename to web-testbed/src/testbed/__init__.py 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/testbed/toga_web_testing/app.py b/web-testbed/src/testbed/app.py similarity index 81% rename from testbed/toga_web_testing/app.py rename to web-testbed/src/testbed/app.py index 7d746797fe..b24320ec11 100644 --- a/testbed/toga_web_testing/app.py +++ b/web-testbed/src/testbed/app.py @@ -1,6 +1,6 @@ import toga from toga.style import Pack -from toga.style.pack import COLUMN, ROW, CENTER +from toga.style.pack import COLUMN try: import js @@ -11,6 +11,7 @@ except ModuleNotFoundError: pyodide = None + class HelloWorld(toga.App): def startup(self): main_box = toga.Box(style=Pack(direction=COLUMN)) @@ -23,14 +24,15 @@ def startup(self): self.main_window = toga.MainWindow(title=self.formal_name) self.main_window.content = main_box self.main_window.show() - + def cmd_test(self, code): local_vars = {} try: - exec(code, {'self': self, 'toga': toga}, local_vars) + exec(code, {"self": self, "toga": toga}, local_vars) return local_vars.get("result", "No result") except Exception as e: - return f'Error: {e}' - + return f"Error: {e}" + + def main(): - return HelloWorld() \ No newline at end of file + return HelloWorld() diff --git a/testbed/toga_web_testing/testbed/tests/__init__.py b/web-testbed/tests/__init__.py similarity index 100% rename from testbed/toga_web_testing/testbed/tests/__init__.py rename to web-testbed/tests/__init__.py diff --git a/testbed/toga_web_testing/testbed/tests/conftest.py b/web-testbed/tests/conftest.py similarity index 68% rename from testbed/toga_web_testing/testbed/tests/conftest.py rename to web-testbed/tests/conftest.py index 629ba65e96..31f6d9e5d8 100644 --- a/testbed/toga_web_testing/testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -1,15 +1,18 @@ -#from pytest import fixture, register_assert_rewrite, skip -#import toga +# from pytest import fixture, register_assert_rewrite, skip +# import toga import pytest + from .tests_backend.proxies.app_proxy import AppProxy + @pytest.fixture(scope="session") def app(): # just return AppProxy return AppProxy() + @pytest.fixture(scope="session") def main_window(app): # return main window created by app proxy - return app.main_window \ No newline at end of file + return app.main_window diff --git a/testbed/toga_web_testing/testbed/tests/data.py b/web-testbed/tests/data.py similarity index 99% rename from testbed/toga_web_testing/testbed/tests/data.py rename to web-testbed/tests/data.py index c639ad4928..fa7fcce382 100644 --- a/testbed/toga_web_testing/testbed/tests/data.py +++ b/web-testbed/tests/data.py @@ -19,4 +19,4 @@ def __str__(self): "你好, wørłd!", 1234, MyObject(), -] \ No newline at end of file +] diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/__init__.py b/web-testbed/tests/tests_backend/__init__.py similarity index 100% rename from testbed/toga_web_testing/testbed/tests/tests_backend/__init__.py rename to web-testbed/tests/tests_backend/__init__.py diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/page_singleton.py b/web-testbed/tests/tests_backend/page_singleton.py similarity index 86% rename from testbed/toga_web_testing/testbed/tests/tests_backend/page_singleton.py rename to web-testbed/tests/tests_backend/page_singleton.py index d69f494fec..c8d70de396 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/page_singleton.py +++ b/web-testbed/tests/tests_backend/page_singleton.py @@ -1,6 +1,9 @@ -import asyncio, threading +import asyncio +import threading + from playwright.async_api import async_playwright + class BackgroundPage: _inst = None _lock = threading.Lock() @@ -48,13 +51,17 @@ async def _bootstrap(self): self._page = await self._context.new_page() await self._page.goto("http://localhost:8080") - #await self._page.goto("http://localhost:8080", wait_until="load", timeout=30_000) + # await self._page.goto( + # "http://localhost:8080", wait_until="load", timeout=30_000 + # ) await self._page.wait_for_timeout(5000) - await self._page.evaluate("(code) => window.test_cmd(code)", "self.my_widgets = {}") + await self._page.evaluate( + "(code) => window.test_cmd(code)", "self.my_widgets = {}" + ) self._alock = asyncio.Lock() - except Exception as e: + except Exception: self._alock = asyncio.Lock() finally: self._ready.set() @@ -77,4 +84,4 @@ async def _runner(): 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)) \ No newline at end of file + return await asyncio.wait_for(asyncio.wrap_future(fut)) diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/probe.py b/web-testbed/tests/tests_backend/probe.py similarity index 90% rename from testbed/toga_web_testing/testbed/tests/tests_backend/probe.py rename to web-testbed/tests/tests_backend/probe.py index 9029ba4a57..9b177b0d15 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/probe.py +++ b/web-testbed/tests/tests_backend/probe.py @@ -1 +1 @@ -# BaseProbe, same with SimpleProbe, maybe don't implement until later. \ No newline at end of file +# BaseProbe, same with SimpleProbe, maybe don't implement until later. diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__init__.py b/web-testbed/tests/tests_backend/proxies/__init__.py similarity index 100% rename from testbed/toga_web_testing/testbed/tests/tests_backend/proxies/__init__.py rename to web-testbed/tests/tests_backend/proxies/__init__.py diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/app_proxy.py b/web-testbed/tests/tests_backend/proxies/app_proxy.py similarity index 78% rename from testbed/toga_web_testing/testbed/tests/tests_backend/proxies/app_proxy.py rename to web-testbed/tests/tests_backend/proxies/app_proxy.py index 14fe18d488..6b4c9ccd13 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/app_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/app_proxy.py @@ -1,7 +1,9 @@ from .main_window_proxy import MainWindowProxy + class AppProxy: """Minimal app proxy: only expose main_window.""" + @property def main_window(self): - return MainWindowProxy() \ No newline at end of file + return MainWindowProxy() diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py similarity index 74% rename from testbed/toga_web_testing/testbed/tests/tests_backend/proxies/box_proxy.py rename to web-testbed/tests/tests_backend/proxies/box_proxy.py index 0124b17779..85a0f254f2 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -1,11 +1,13 @@ from ..page_singleton import BackgroundPage + class BoxProxy: """Proxy for toga.Box(children=[...]).""" + def __init__(self, children=None): - #Create box object remotely + # Create box object remotely self.id = self._create_remote_box() - #If there's children, add them + # If there's children, add them if children: for child in children: self.add(child) @@ -27,7 +29,5 @@ def _create_remote_box(self): def add(self, widget): page = BackgroundPage.get() - code = ( - f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" - ) - page.eval_js("(code) => window.test_cmd(code)", code) \ No newline at end of file + code = f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" + page.eval_js("(code) => window.test_cmd(code)", code) diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/button_proxy.py b/web-testbed/tests/tests_backend/proxies/button_proxy.py similarity index 83% rename from testbed/toga_web_testing/testbed/tests/tests_backend/proxies/button_proxy.py rename to web-testbed/tests/tests_backend/proxies/button_proxy.py index cfcc80ec62..7ba374859c 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/button_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/button_proxy.py @@ -1,5 +1,5 @@ from ..page_singleton import BackgroundPage -import json + class ButtonProxy: def __init__(self): @@ -9,15 +9,19 @@ def __init__(self): object.__setattr__(self, "id", button_id) object.__setattr__(self, "_inited", True) - + def __setattr__(self, name, value): page = BackgroundPage.get() widget_id = object.__getattribute__(self, "id") # METHOD 1 (working) - - literal = repr(str(value)) if not isinstance(value, (int, float, bool, type(None))) else repr(value) - + + literal = ( + repr(str(value)) + if not isinstance(value, (int, float, bool, type(None))) + else repr(value) + ) + # METHOD 2 (working) """ try: @@ -42,9 +46,7 @@ def __setattr__(self, name, value): def __getattr__(self, name): page = BackgroundPage.get() - code = ( - f"result = self.my_widgets['{self.id}'].{name}" - ) + code = f"result = self.my_widgets['{self.id}'].{name}" return page.eval_js("(code) => window.test_cmd(code)", code) @@ -56,5 +58,3 @@ def setup(self): "result = new_widget.id" ) return page.eval_js("(code) => window.test_cmd(code)", code) - - diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/main_window_proxy.py b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py similarity index 96% rename from testbed/toga_web_testing/testbed/tests/tests_backend/proxies/main_window_proxy.py rename to web-testbed/tests/tests_backend/proxies/main_window_proxy.py index 78246d29b2..a5e40aac4f 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/proxies/main_window_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py @@ -1,6 +1,7 @@ from ..page_singleton import BackgroundPage from .box_proxy import BoxProxy + class MainWindowProxy: """Proxy that can get/set content. Content must be a BoxProxy.""" diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/__init__.py b/web-testbed/tests/tests_backend/widgets/__init__.py similarity index 100% rename from testbed/toga_web_testing/testbed/tests/tests_backend/widgets/__init__.py rename to web-testbed/tests/tests_backend/widgets/__init__.py diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/base.py b/web-testbed/tests/tests_backend/widgets/base.py similarity index 90% rename from testbed/toga_web_testing/testbed/tests/tests_backend/widgets/base.py rename to web-testbed/tests/tests_backend/widgets/base.py index a6c2b8dd56..f5c05341a9 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/base.py +++ b/web-testbed/tests/tests_backend/widgets/base.py @@ -1 +1 @@ -# SimpleProbe, same with BaseProbe, maybe don't implement until later. \ No newline at end of file +# SimpleProbe, same with BaseProbe, maybe don't implement until later. diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py similarity index 73% rename from testbed/toga_web_testing/testbed/tests/tests_backend/widgets/button.py rename to web-testbed/tests/tests_backend/widgets/button.py index 95f848ffaa..93afb8e7c8 100644 --- a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -1,5 +1,6 @@ from ..page_singleton import BackgroundPage + class ButtonProbe: def __init__(self, widget): object.__setattr__(self, "id", widget.id) @@ -10,14 +11,19 @@ def __getattr__(self, name): match name: case "text": - # was inner_text, but it trims leading/trailing spaces and removes only whitespace. - return page.run_coro(lambda page: page.locator(f"#{self.dom_id}").text_content()) + # was inner_text, but it trims leading/trailing spaces and removes only + # whitespace. + return page.run_coro( + lambda page: page.locator(f"#{self.dom_id}").text_content() + ) case "height": - box = page.run_coro(lambda page: page.locator(f"#{self.dom_id}").bounding_box()) + box = page.run_coro( + lambda page: page.locator(f"#{self.dom_id}").bounding_box() + ) return None if box is None else box["height"] - + return "No match" - #raise AttributeError(name) + # raise AttributeError(name) """ ALTERNATIVE METHOD - Keep just in case sel = f"#{self.dom_id}" @@ -33,7 +39,3 @@ async def _height(page): return None if box is None else box["height"] return w.run_coro(_height) """ - - - - diff --git a/testbed/toga_web_testing/testbed/tests/tests_backend/widgets/label.py b/web-testbed/tests/tests_backend/widgets/label.py similarity index 100% rename from testbed/toga_web_testing/testbed/tests/tests_backend/widgets/label.py rename to web-testbed/tests/tests_backend/widgets/label.py diff --git a/testbed/toga_web_testing/testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py similarity index 53% rename from testbed/toga_web_testing/testbed/tests/widgets/conftest.py rename to web-testbed/tests/widgets/conftest.py index 0c9c0bd95d..66ecfd227f 100644 --- a/testbed/toga_web_testing/testbed/tests/widgets/conftest.py +++ b/web-testbed/tests/widgets/conftest.py @@ -1,15 +1,18 @@ import pytest -#import toga + +# import toga from probe import get_probe -#from .probe import get_probe + +# from .probe import get_probe 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. -@pytest.fixture -async def widget(): - raise NotImplementedError("test modules must define a `widget` fixture") -""" +# from ..tests_backend.proxies.box_proxy import BoxProxy + +# TODO: Don't enable until below is implemented. +# @pytest.fixture +# async def widget(): +# raise NotImplementedError("test modules must define a `widget` fixture") + @pytest.fixture async def probe(main_window, widget): @@ -19,4 +22,3 @@ async def probe(main_window, widget): probe = get_probe(widget) yield probe main_window.content = old_content - \ No newline at end of file diff --git a/web-testbed/tests/widgets/probe.py b/web-testbed/tests/widgets/probe.py new file mode 100644 index 0000000000..384b88e665 --- /dev/null +++ b/web-testbed/tests/widgets/probe.py @@ -0,0 +1,10 @@ +from importlib import import_module + + +def get_probe(widget): + name = type(widget).__name__ # e.g. "ButtonProxy" + base = name.removesuffix("Proxy") # -> "Button" + module = import_module( + f"tests.tests_backend.widgets.{base.lower()}" + ) # -> tests/tests_backend/widgets/button.py + return getattr(module, f"{base}Probe")(widget) diff --git a/testbed/toga_web_testing/testbed/tests/widgets/properties.py b/web-testbed/tests/widgets/properties.py similarity index 90% rename from testbed/toga_web_testing/testbed/tests/widgets/properties.py rename to web-testbed/tests/widgets/properties.py index a616b5727c..bef08b843b 100644 --- a/testbed/toga_web_testing/testbed/tests/widgets/properties.py +++ b/web-testbed/tests/widgets/properties.py @@ -1 +1 @@ -# Maybe don't implement until got more widget-specific tests complete. \ No newline at end of file +# Maybe don't implement until got more widget-specific tests complete. diff --git a/testbed/toga_web_testing/testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py similarity index 56% rename from testbed/toga_web_testing/testbed/tests/widgets/test_button.py rename to web-testbed/tests/widgets/test_button.py index d6d6e6e83e..cff491f157 100644 --- a/testbed/toga_web_testing/testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -1,17 +1,17 @@ # Handled differently in real testing with get_module() -from tests.tests_backend.widgets.button import ButtonProbe -from tests.tests_backend.proxies.button_proxy import ButtonProxy -#from ..tests_backend.widgets.button import ButtonProbe -#from ..tests_backend.proxies.button_proxy import ButtonProxy +from pytest import approx, fixture +# from ..tests_backend.widgets.button import ButtonProbe +# from ..tests_backend.proxies.button_proxy import ButtonProxy from tests.data import TEXTS +from tests.tests_backend.proxies.button_proxy import ButtonProxy -from pytest import approx, fixture @fixture async def widget(): return ButtonProxy() + async def test_text(widget, probe): "The text displayed on a button can be changed" initial_height = probe.height @@ -20,7 +20,7 @@ async def test_text(widget, probe): widget.text = text # no-op - #await probe.redraw(f"Button text should be {text}") + # await probe.redraw(f"Button text should be {text}") # Text after a newline will be stripped. assert isinstance(widget.text, str) @@ -31,18 +31,15 @@ async def test_text(widget, probe): assert probe.height == approx(initial_height, abs=1) - -""" -async def test_text_change(widget, probe): - initial_height = probe.height - - widget.text = "new text" - - assert isinstance(widget.text, str) - expected = str("new text").split("\n")[0] - - assert widget.text == expected - assert probe.text == expected - - assert probe.height == approx(initial_height, abs=1) -""" \ No newline at end of file +# async def test_text_change(widget, probe): +# initial_height = probe.height +# +# widget.text = "new text" +# +# assert isinstance(widget.text, str) +# expected = str("new text").split("\n")[0] +# +# assert widget.text == expected +# assert probe.text == expected +# +# assert probe.height == approx(initial_height, abs=1) diff --git a/testbed/toga_web_testing/testbed/tests/widgets/test_label.py b/web-testbed/tests/widgets/test_label.py similarity index 100% rename from testbed/toga_web_testing/testbed/tests/widgets/test_label.py rename to web-testbed/tests/widgets/test_label.py From 6b94754ab149bf5810bc8567887d7a2fd156b175 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Fri, 5 Sep 2025 10:42:54 +0800 Subject: [PATCH 04/37] Fix web-testbed/pyproject.toml. Changes made by pre-commit --- positron/src/positron/django_templates/manage.py.tmpl | 1 + web-testbed/pyproject.toml | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) 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/web-testbed/pyproject.toml b/web-testbed/pyproject.toml index 29d833ad9b..da6869dba9 100644 --- a/web-testbed/pyproject.toml +++ b/web-testbed/pyproject.toml @@ -2,7 +2,8 @@ name = "testbed" version = "0.0.1" -[dependency-groups] +#[dependency-groups] +[project.optional-dependencies] test = [ "briefcase", "playwright == 1.51.0", @@ -45,10 +46,5 @@ requires = [ ] style_framework = "Shoelace v2.3" -# Attempted integrating async operations, but it required calls like 'widget.text' -# to need 'await' when calling, which is not the case in Toga test methods. - -# Uncomment below if using async. - [tool.pytest.ini_options] asyncio_mode = "auto" From f57616de0229af4cf18f17021a57c597fe5a5306 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Sat, 6 Sep 2025 12:10:31 +0800 Subject: [PATCH 05/37] Remove page singleton method, using class injection now. Experimented with a new proxy class architecture, only implemented with ButtonProxy. --- web-testbed/pyproject.toml | 1 - web-testbed/tests/conftest.py | 27 +++++++++ .../tests/tests_backend/page_singleton.py | 18 +----- .../tests/tests_backend/proxies/base.py | 41 ++++++++++++++ .../tests/tests_backend/proxies/box_proxy.py | 14 ++--- .../tests_backend/proxies/button_proxy.py | 56 ++----------------- .../proxies/main_window_proxy.py | 12 ++-- .../tests/tests_backend/widgets/button.py | 17 +++--- web-testbed/tests/widgets/conftest.py | 10 +--- 9 files changed, 104 insertions(+), 92 deletions(-) create mode 100644 web-testbed/tests/tests_backend/proxies/base.py diff --git a/web-testbed/pyproject.toml b/web-testbed/pyproject.toml index da6869dba9..46a60dfcd5 100644 --- a/web-testbed/pyproject.toml +++ b/web-testbed/pyproject.toml @@ -2,7 +2,6 @@ name = "testbed" version = "0.0.1" -#[dependency-groups] [project.optional-dependencies] test = [ "briefcase", diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 31f6d9e5d8..80b35436c5 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -3,7 +3,34 @@ import pytest +from .tests_backend.page_singleton import BackgroundPage + +# In future, would only need to be AppProxy, MainWindowProxy, ProxyBase and +# a SimpleProbe/BaseProbe. +# Possibly only ProxyBase and SimpleProbe/BaseProbe. from .tests_backend.proxies.app_proxy import AppProxy +from .tests_backend.proxies.base import ProxyBase +from .tests_backend.proxies.box_proxy import BoxProxy +from .tests_backend.proxies.main_window_proxy import MainWindowProxy +from .tests_backend.widgets.button import ButtonProbe + + +# With this page injection method, we could possibly extend so that +# multiple pages can be created and be running at once (if it is ever +# needed in the future). Would need to add a method/fixture to store +# and switch between them. +@pytest.fixture(scope="session") +def page(): + p = BackgroundPage() + yield p + + +@pytest.fixture(scope="session", autouse=True) +def _wire_page(page): + ProxyBase.page_provider = staticmethod(lambda: page) + BoxProxy.page_provider = staticmethod(lambda: page) + MainWindowProxy.page_provider = staticmethod(lambda: page) + ButtonProbe.page_provider = staticmethod(lambda: page) @pytest.fixture(scope="session") diff --git a/web-testbed/tests/tests_backend/page_singleton.py b/web-testbed/tests/tests_backend/page_singleton.py index c8d70de396..229e5ce54d 100644 --- a/web-testbed/tests/tests_backend/page_singleton.py +++ b/web-testbed/tests/tests_backend/page_singleton.py @@ -5,26 +5,13 @@ class BackgroundPage: - _inst = None - _lock = threading.Lock() - - def __new__(cls): - with cls._lock: - if cls._inst is None: - cls._inst = super().__new__(cls) - return cls._inst - - @classmethod - def get(cls): - return cls() - def __init__(self): if getattr(self, "_init", False): return self._init = True self._ready = threading.Event() self._loop = None - self._thread = threading.Thread(target=self._run, name="PageLoop", daemon=True) + self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() self._ready.wait() @@ -62,8 +49,9 @@ async def _bootstrap(self): self._alock = asyncio.Lock() except Exception: - self._alock = asyncio.Lock() + raise finally: + self._alock = asyncio.Lock() self._ready.set() async def _eval(self, js, *args): diff --git a/web-testbed/tests/tests_backend/proxies/base.py b/web-testbed/tests/tests_backend/proxies/base.py new file mode 100644 index 0000000000..c2d3ce9135 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/base.py @@ -0,0 +1,41 @@ +class ProxyBase: + page_provider = staticmethod(lambda: None) + + def _page(self): + return type(self).page_provider() + + # Extend to also hold other objects, including app and main_window? + def __str__(self): + widget_id = object.__getattribute__(self, "id") + return f"self.my_widgets[{widget_id!r}]" + + def __setattr__(self, name, value): + # METHOD 1 (working) + literal = ( + repr(str(value)) + if not isinstance(value, (int, float, bool, type(None))) + else repr(value) + ) + + # METHOD 2 (working) + # try: + # literal = json.dumps(value) + # except TypeError: + # literal = json.dumps(str(value)) + + # METHOD 3 (working) + # if name == "text": + # literal = repr(str(value)) + # else: + # try: + # literal = json.dumps(value) + # except TypeError: + # literal = repr(value) + + code = f"{str(self)}.{name} = {literal}" + self._page().eval_js("(code) => window.test_cmd(code)", code) + + def __getattr__(self, name): + code = f"result = {str(self)}.{name}" + + return self._page().eval_js("(code) => window.test_cmd(code)", code) diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py index 85a0f254f2..5c03bf518b 100644 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -1,9 +1,11 @@ -from ..page_singleton import BackgroundPage - - class BoxProxy: """Proxy for toga.Box(children=[...]).""" + page_provider = staticmethod(lambda: None) + + def _page(self): + return type(self).page_provider() + def __init__(self, children=None): # Create box object remotely self.id = self._create_remote_box() @@ -19,15 +21,13 @@ def _from_id(cls, box_id: str): return obj def _create_remote_box(self): - page = BackgroundPage.get() code = ( "new_box = toga.Box()\n" "self.my_widgets[new_box.id] = new_box\n" "result = new_box.id" ) - return page.eval_js("(code) => window.test_cmd(code)", code) + return self._page().eval_js("(code) => window.test_cmd(code)", code) def add(self, widget): - page = BackgroundPage.get() code = f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" - page.eval_js("(code) => window.test_cmd(code)", code) + self._page().eval_js("(code) => window.test_cmd(code)", code) diff --git a/web-testbed/tests/tests_backend/proxies/button_proxy.py b/web-testbed/tests/tests_backend/proxies/button_proxy.py index 7ba374859c..799d0de248 100644 --- a/web-testbed/tests/tests_backend/proxies/button_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/button_proxy.py @@ -1,60 +1,16 @@ -from ..page_singleton import BackgroundPage +from .base import ProxyBase -class ButtonProxy: +class ButtonProxy(ProxyBase): def __init__(self): object.__setattr__(self, "_inited", False) - button_id = self.setup() - object.__setattr__(self, "id", button_id) - - object.__setattr__(self, "_inited", True) - - def __setattr__(self, name, value): - page = BackgroundPage.get() - widget_id = object.__getattribute__(self, "id") - - # METHOD 1 (working) - - literal = ( - repr(str(value)) - if not isinstance(value, (int, float, bool, type(None))) - else repr(value) - ) - - # METHOD 2 (working) - """ - try: - literal = json.dumps(value) - except TypeError: - literal = json.dumps(str(value)) - """ - # METHOD 3 (working) - """ - if name == "text": - literal = repr(str(value)) - else: - try: - literal = json.dumps(value) - except TypeError: - literal = repr(value) - """ - - code = f"self.my_widgets[{widget_id!r}].{name} = {literal}" - page.eval_js("(code) => window.test_cmd(code)", code) - - def __getattr__(self, name): - page = BackgroundPage.get() - - code = f"result = self.my_widgets['{self.id}'].{name}" - - return page.eval_js("(code) => window.test_cmd(code)", code) - - def setup(self): - page = BackgroundPage.get() code = ( "new_widget = toga.Button('Hello')\n" "self.my_widgets[new_widget.id] = new_widget\n" "result = new_widget.id" ) - return page.eval_js("(code) => window.test_cmd(code)", code) + widget_id = self._page().eval_js("(code) => window.test_cmd(code)", code) + + object.__setattr__(self, "id", widget_id) + object.__setattr__(self, "_inited", True) diff --git a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py index a5e40aac4f..5ff983a64e 100644 --- a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py @@ -1,15 +1,18 @@ -from ..page_singleton import BackgroundPage from .box_proxy import BoxProxy class MainWindowProxy: """Proxy that can get/set content. Content must be a BoxProxy.""" + page_provider = staticmethod(lambda: None) + + def _page(self): + return type(self).page_provider() + @property def content(self): - page = BackgroundPage.get() code = "result = self.main_window.content.id" - box_id = page.eval_js("(code) => window.test_cmd(code)", code) + box_id = self._page().eval_js("(code) => window.test_cmd(code)", code) if box_id is None: return BoxProxy() proxy = BoxProxy.__new__(BoxProxy) @@ -18,6 +21,5 @@ def content(self): @content.setter def content(self, box_proxy): - page = BackgroundPage.get() code = f"self.main_window.content = self.my_widgets['{box_proxy.id}']" - page.eval_js("(code) => window.test_cmd(code)", code) + self._page().eval_js("(code) => window.test_cmd(code)", code) diff --git a/web-testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py index 93afb8e7c8..33fc108a62 100644 --- a/web-testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -1,31 +1,34 @@ -from ..page_singleton import BackgroundPage +class ButtonProbe: + page_provider = staticmethod(lambda: None) + def _page(self): + return type(self).page_provider() -class ButtonProbe: def __init__(self, widget): object.__setattr__(self, "id", widget.id) object.__setattr__(self, "dom_id", f"toga_{widget.id}") def __getattr__(self, name): - page = BackgroundPage.get() + page = self._page() match name: case "text": - # was inner_text, but it trims leading/trailing spaces and removes only + # Was inner_text, but it trims leading/trailing spaces and removes only # whitespace. return page.run_coro( - lambda page: page.locator(f"#{self.dom_id}").text_content() + lambda p: p.locator(f"#{self.dom_id}").text_content() ) case "height": box = page.run_coro( - lambda page: page.locator(f"#{self.dom_id}").bounding_box() + lambda p: p.locator(f"#{self.dom_id}").bounding_box() ) return None if box is None else box["height"] return "No match" # raise AttributeError(name) - """ ALTERNATIVE METHOD - Keep just in case + # Alternate Method - Keep just in case + """ sel = f"#{self.dom_id}" if name == "text": diff --git a/web-testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py index 66ecfd227f..6612537e85 100644 --- a/web-testbed/tests/widgets/conftest.py +++ b/web-testbed/tests/widgets/conftest.py @@ -2,16 +2,12 @@ # import toga from probe import get_probe - -# from .probe import get_probe 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. -# @pytest.fixture -# async def widget(): -# raise NotImplementedError("test modules must define a `widget` fixture") +@pytest.fixture +async def widget(): + raise NotImplementedError("test modules must define a `widget` fixture") @pytest.fixture From c914cd727dfd10b3dcf671a891c5460b9eb1c55e Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:27:14 +0800 Subject: [PATCH 06/37] Added base proxy to be more dynamic --- .../tests/tests_backend/proxies/base_proxy.py | 88 +++++++++++++++++++ .../tests_backend/proxies/button_proxy.py | 16 ++-- 2 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 web-testbed/tests/tests_backend/proxies/base_proxy.py 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..131615d8a0 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -0,0 +1,88 @@ +import json +from ..page_singleton import BackgroundPage + +class BaseProxy: + page_provider = staticmethod(lambda: None) + + def _page(self): + return type(self).page_provider() + + def __init__(self, widget_id: str): + object.__setattr__(self, "_id", widget_id) + + @property + def id(self) -> str: + return object.__getattribute__(self, "_id") + + @property + def js_ref(self) -> str: + return f"self.my_widgets[{repr(self.id)}]" + + @classmethod + def from_id(cls, widget_id: str) -> "BaseProxy": + return cls(widget_id) + + def _is_function(self, name: str) -> bool: + prop = repr(name) + code = ( + f"_obj = {self.js_ref}\n" + f"_attr = getattr(_obj, {prop})\n" + f"result = callable(_attr)" + ) + return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) + + def _encode_value(self, value) -> str: + #other proxy, pass by reference + if isinstance(value, BaseProxy): + return value.js_ref + #if plain primitives, embed as python literal + if isinstance(value, (str, int, float, bool)) or value is None: + return repr(value) + #everything else use text form (what Toga expects for .text, etc) + return repr(str(value)) + + def __setattr__(self, name, value): + prop = repr(name) + + if name.startswith("_"): + return object.__setattr__(self, name, value) + if name == "id": + raise AttributeError("Proxy 'id' is read-only") + + rhs = self._encode_value(value) + + code = f"setattr({self.js_ref}, {prop}, {rhs})" + self._page().eval_js("(code) => window.test_cmd(code)", code) + + + def __getattr__(self, name): + prop = repr(name) + + # If it's a function on the remote side, return a Python wrapper + if self._is_function(name): + def _method(*args): + parts = [self._encode_value(a) for a in args] + args_py = ", ".join(parts) + code = ( + f"_obj = {self.js_ref}\n" + f"_fn = getattr(_obj, {prop})\n" + f"result = _fn({args_py})" + ) + return self._page().eval_js("(code) => window.test_cmd(code)", code) + return _method + + # else plain property get + code = f"result = getattr({self.js_ref}, {prop})" + return self._page().eval_js("(code) => window.test_cmd(code)", code) + + def add_to_main_window(self): + self._page().eval_js("(code) => window.test_cmd(code)", f"self.main_window.content.add({self.js_ref})") + + def __repr__(self): + return f"" + + def __str__(self): + return f"WidgetProxy({self.id})" + + + \ No newline at end of file diff --git a/web-testbed/tests/tests_backend/proxies/button_proxy.py b/web-testbed/tests/tests_backend/proxies/button_proxy.py index 799d0de248..0b85eca7dd 100644 --- a/web-testbed/tests/tests_backend/proxies/button_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/button_proxy.py @@ -1,16 +1,12 @@ -from .base import ProxyBase +from .base_proxy import BaseProxy -class ButtonProxy(ProxyBase): - def __init__(self): - object.__setattr__(self, "_inited", False) - +class ButtonProxy(BaseProxy): + def __init__(self, text="Hello"): code = ( - "new_widget = toga.Button('Hello')\n" + f"new_widget = toga.Button({repr(text)})\n" "self.my_widgets[new_widget.id] = new_widget\n" "result = new_widget.id" ) - widget_id = self._page().eval_js("(code) => window.test_cmd(code)", code) - - object.__setattr__(self, "id", widget_id) - object.__setattr__(self, "_inited", True) + wid = self._page().eval_js("(code) => window.test_cmd(code)", code) + super().__init__(wid) From 7b87ea9f1b0d9d88f74fba1c5b9dbffc16a0ad3d Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Sun, 7 Sep 2025 19:29:12 +0800 Subject: [PATCH 07/37] Modify conftest to reflect recent commits --- web-testbed/tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 80b35436c5..3d1cb741e8 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -9,7 +9,7 @@ # a SimpleProbe/BaseProbe. # Possibly only ProxyBase and SimpleProbe/BaseProbe. from .tests_backend.proxies.app_proxy import AppProxy -from .tests_backend.proxies.base import ProxyBase +from .tests_backend.proxies.base_proxy import BaseProxy from .tests_backend.proxies.box_proxy import BoxProxy from .tests_backend.proxies.main_window_proxy import MainWindowProxy from .tests_backend.widgets.button import ButtonProbe @@ -27,7 +27,7 @@ def page(): @pytest.fixture(scope="session", autouse=True) def _wire_page(page): - ProxyBase.page_provider = staticmethod(lambda: 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) From e147f071ada982563803ac07ad835aee6fad88f3 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:27:20 +0800 Subject: [PATCH 08/37] Deleted base.py --- .../tests/tests_backend/proxies/base.py | 41 ------------------- 1 file changed, 41 deletions(-) delete mode 100644 web-testbed/tests/tests_backend/proxies/base.py diff --git a/web-testbed/tests/tests_backend/proxies/base.py b/web-testbed/tests/tests_backend/proxies/base.py deleted file mode 100644 index c2d3ce9135..0000000000 --- a/web-testbed/tests/tests_backend/proxies/base.py +++ /dev/null @@ -1,41 +0,0 @@ -class ProxyBase: - page_provider = staticmethod(lambda: None) - - def _page(self): - return type(self).page_provider() - - # Extend to also hold other objects, including app and main_window? - def __str__(self): - widget_id = object.__getattribute__(self, "id") - return f"self.my_widgets[{widget_id!r}]" - - def __setattr__(self, name, value): - # METHOD 1 (working) - literal = ( - repr(str(value)) - if not isinstance(value, (int, float, bool, type(None))) - else repr(value) - ) - - # METHOD 2 (working) - # try: - # literal = json.dumps(value) - # except TypeError: - # literal = json.dumps(str(value)) - - # METHOD 3 (working) - # if name == "text": - # literal = repr(str(value)) - # else: - # try: - # literal = json.dumps(value) - # except TypeError: - # literal = repr(value) - - code = f"{str(self)}.{name} = {literal}" - self._page().eval_js("(code) => window.test_cmd(code)", code) - - def __getattr__(self, name): - code = f"result = {str(self)}.{name}" - - return self._page().eval_js("(code) => window.test_cmd(code)", code) From 99a4658f24e13a79ad0b5bef5171cc96bf95105f Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Sun, 7 Sep 2025 20:43:27 +0800 Subject: [PATCH 09/37] Changed the wait times for page to 7 secs --- .../tests/tests_backend/page_singleton.py | 150 +++++++++--------- 1 file changed, 75 insertions(+), 75 deletions(-) diff --git a/web-testbed/tests/tests_backend/page_singleton.py b/web-testbed/tests/tests_backend/page_singleton.py index 229e5ce54d..bd6fcd7e28 100644 --- a/web-testbed/tests/tests_backend/page_singleton.py +++ b/web-testbed/tests/tests_backend/page_singleton.py @@ -1,75 +1,75 @@ -import asyncio -import threading - -from playwright.async_api import async_playwright - - -class BackgroundPage: - def __init__(self): - if getattr(self, "_init", False): - return - 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): - try: - self._play = await async_playwright().start() - self._browser = await self._play.chromium.launch(headless=True) - self._context = await self._browser.new_context() - self._page = await self._context.new_page() - - await self._page.goto("http://localhost:8080") - # await self._page.goto( - # "http://localhost:8080", wait_until="load", timeout=30_000 - # ) - await self._page.wait_for_timeout(5000) - - await self._page.evaluate( - "(code) => window.test_cmd(code)", "self.my_widgets = {}" - ) - - self._alock = asyncio.Lock() - except Exception: - raise - finally: - 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)) +import asyncio +import threading + +from playwright.async_api import async_playwright + + +class BackgroundPage: + def __init__(self): + if getattr(self, "_init", False): + return + 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): + try: + self._play = await async_playwright().start() + self._browser = await self._play.chromium.launch(headless=True) + self._context = await self._browser.new_context() + self._page = await self._context.new_page() + + await self._page.goto("http://localhost:8080/") + # await self._page.goto( + # "http://localhost:8080", wait_until="load", timeout=30_000 + # ) + await self._page.wait_for_timeout(7000) + + await self._page.evaluate( + "(code) => window.test_cmd(code)", "self.my_widgets = {}" + ) + + self._alock = asyncio.Lock() + except Exception: + raise + finally: + 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)) From 91373bf6b4076ed3979d79333d0bcd2db4fbb3b6 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Mon, 8 Sep 2025 09:31:30 +0800 Subject: [PATCH 10/37] Make Button probe non-dynamic. Other minor changes. --- .../tests/tests_backend/proxies/box_proxy.py | 2 + .../proxies/main_window_proxy.py | 2 +- .../tests/tests_backend/widgets/button.py | 56 ++++++++----------- web-testbed/tests/widgets/test_button.py | 18 ------ 4 files changed, 27 insertions(+), 51 deletions(-) diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py index 5c03bf518b..0e354bf4f3 100644 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -1,4 +1,6 @@ class BoxProxy: + # Currently only for use in the 'probe' pytest fixture. + """Proxy for toga.Box(children=[...]).""" page_provider = staticmethod(lambda: None) diff --git a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py index 5ff983a64e..3fd171797b 100644 --- a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py @@ -2,7 +2,7 @@ class MainWindowProxy: - """Proxy that can get/set content. Content must be a BoxProxy.""" + """Minimal proxy that can get/set content. Content must be a BoxProxy.""" page_provider = staticmethod(lambda: None) diff --git a/web-testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py index 33fc108a62..ee9b5c85e2 100644 --- a/web-testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -8,37 +8,29 @@ def __init__(self, widget): object.__setattr__(self, "id", widget.id) object.__setattr__(self, "dom_id", f"toga_{widget.id}") - def __getattr__(self, name): + @property + def text(self): page = self._page() + return page.run_coro(lambda p: p.locator(f"#{self.dom_id}").text_content()) - match name: - case "text": - # Was inner_text, but it trims leading/trailing spaces and removes only - # whitespace. - return page.run_coro( - lambda p: p.locator(f"#{self.dom_id}").text_content() - ) - case "height": - box = page.run_coro( - lambda p: p.locator(f"#{self.dom_id}").bounding_box() - ) - return None if box is None else box["height"] - - return "No match" - # raise AttributeError(name) - - # Alternate Method - Keep just in case - """ - 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) - """ + @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"] + + # 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) + """ diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py index cff491f157..0502a087dc 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -1,8 +1,4 @@ -# Handled differently in real testing with get_module() from pytest import approx, fixture - -# from ..tests_backend.widgets.button import ButtonProbe -# from ..tests_backend.proxies.button_proxy import ButtonProxy from tests.data import TEXTS from tests.tests_backend.proxies.button_proxy import ButtonProxy @@ -29,17 +25,3 @@ async def test_text(widget, probe): 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_text_change(widget, probe): -# initial_height = probe.height -# -# widget.text = "new text" -# -# assert isinstance(widget.text, str) -# expected = str("new text").split("\n")[0] -# -# assert widget.text == expected -# assert probe.text == expected -# -# assert probe.height == approx(initial_height, abs=1) From 94609a3a8d97c358257bfabb60456666ed0820b6 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Mon, 8 Sep 2025 09:40:23 +0800 Subject: [PATCH 11/37] Rename 'page_singleton.py' to 'playwright_page.py'. --- web-testbed/tests/conftest.py | 2 +- .../{page_singleton.py => playwright_page.py} | 150 +++++++++--------- .../tests/tests_backend/proxies/base_proxy.py | 32 ++-- 3 files changed, 91 insertions(+), 93 deletions(-) rename web-testbed/tests/tests_backend/{page_singleton.py => playwright_page.py} (97%) diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 3d1cb741e8..9da12bbe03 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from .tests_backend.page_singleton import BackgroundPage +from .tests_backend.playwright_page import BackgroundPage # In future, would only need to be AppProxy, MainWindowProxy, ProxyBase and # a SimpleProbe/BaseProbe. diff --git a/web-testbed/tests/tests_backend/page_singleton.py b/web-testbed/tests/tests_backend/playwright_page.py similarity index 97% rename from web-testbed/tests/tests_backend/page_singleton.py rename to web-testbed/tests/tests_backend/playwright_page.py index bd6fcd7e28..0026a177ba 100644 --- a/web-testbed/tests/tests_backend/page_singleton.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -1,75 +1,75 @@ -import asyncio -import threading - -from playwright.async_api import async_playwright - - -class BackgroundPage: - def __init__(self): - if getattr(self, "_init", False): - return - 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): - try: - self._play = await async_playwright().start() - self._browser = await self._play.chromium.launch(headless=True) - self._context = await self._browser.new_context() - self._page = await self._context.new_page() - - await self._page.goto("http://localhost:8080/") - # await self._page.goto( - # "http://localhost:8080", wait_until="load", timeout=30_000 - # ) - await self._page.wait_for_timeout(7000) - - await self._page.evaluate( - "(code) => window.test_cmd(code)", "self.my_widgets = {}" - ) - - self._alock = asyncio.Lock() - except Exception: - raise - finally: - 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)) +import asyncio +import threading + +from playwright.async_api import async_playwright + + +class BackgroundPage: + def __init__(self): + if getattr(self, "_init", False): + return + 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): + try: + self._play = await async_playwright().start() + self._browser = await self._play.chromium.launch(headless=True) + self._context = await self._browser.new_context() + self._page = await self._context.new_page() + + await self._page.goto("http://localhost:8080/") + # await self._page.goto( + # "http://localhost:8080", wait_until="load", timeout=30_000 + # ) + await self._page.wait_for_timeout(7000) + + await self._page.evaluate( + "(code) => window.test_cmd(code)", "self.my_widgets = {}" + ) + + self._alock = asyncio.Lock() + except Exception: + raise + finally: + 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/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index 131615d8a0..489d88aa25 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -1,15 +1,12 @@ -import json -from ..page_singleton import BackgroundPage - class BaseProxy: page_provider = staticmethod(lambda: None) def _page(self): return type(self).page_provider() - + def __init__(self, widget_id: str): object.__setattr__(self, "_id", widget_id) - + @property def id(self) -> str: return object.__getattribute__(self, "_id") @@ -21,7 +18,7 @@ def js_ref(self) -> str: @classmethod def from_id(cls, widget_id: str) -> "BaseProxy": return cls(widget_id) - + def _is_function(self, name: str) -> bool: prop = repr(name) code = ( @@ -30,15 +27,15 @@ def _is_function(self, name: str) -> bool: f"result = callable(_attr)" ) return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) - + def _encode_value(self, value) -> str: - #other proxy, pass by reference + # other proxy, pass by reference if isinstance(value, BaseProxy): return value.js_ref - #if plain primitives, embed as python literal + # if plain primitives, embed as python literal if isinstance(value, (str, int, float, bool)) or value is None: return repr(value) - #everything else use text form (what Toga expects for .text, etc) + # everything else use text form (what Toga expects for .text, etc) return repr(str(value)) def __setattr__(self, name, value): @@ -47,19 +44,19 @@ def __setattr__(self, name, value): if name.startswith("_"): return object.__setattr__(self, name, value) if name == "id": - raise AttributeError("Proxy 'id' is read-only") + raise AttributeError("Proxy 'id' is read-only") rhs = self._encode_value(value) - + code = f"setattr({self.js_ref}, {prop}, {rhs})" self._page().eval_js("(code) => window.test_cmd(code)", code) - def __getattr__(self, name): prop = repr(name) # If it's a function on the remote side, return a Python wrapper if self._is_function(name): + def _method(*args): parts = [self._encode_value(a) for a in args] args_py = ", ".join(parts) @@ -69,6 +66,7 @@ def _method(*args): f"result = _fn({args_py})" ) return self._page().eval_js("(code) => window.test_cmd(code)", code) + return _method # else plain property get @@ -76,13 +74,13 @@ def _method(*args): return self._page().eval_js("(code) => window.test_cmd(code)", code) def add_to_main_window(self): - self._page().eval_js("(code) => window.test_cmd(code)", f"self.main_window.content.add({self.js_ref})") + self._page().eval_js( + "(code) => window.test_cmd(code)", + f"self.main_window.content.add({self.js_ref})", + ) def __repr__(self): return f"" def __str__(self): return f"WidgetProxy({self.id})" - - - \ No newline at end of file From 7678c9e2c6e94bc8ef2e71b88ba7c1bdcd36dd6e Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Sun, 14 Sep 2025 13:48:19 +0800 Subject: [PATCH 12/37] Works with button test_text. Trying new proxy architecture with non-widget 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. --- web-testbed/tests/conftest.py | 6 +- .../tests/tests_backend/playwright_page.py | 17 +++- .../tests_backend/proxies/attribute_proxy.py | 7 ++ .../tests/tests_backend/proxies/base_proxy.py | 88 +++--------------- .../tests/tests_backend/proxies/box_proxy.py | 9 ++ .../tests_backend/proxies/button_proxy.py | 17 ++-- .../tests/tests_backend/proxies/expr_proxy.py | 91 +++++++++++++++++++ .../tests_backend/proxies/non_widget_proxy.py | 17 ++++ .../tests_backend/proxies/widget_proxy.py | 21 +++++ web-testbed/tests/widgets/test_button.py | 2 +- 10 files changed, 183 insertions(+), 92 deletions(-) create mode 100644 web-testbed/tests/tests_backend/proxies/attribute_proxy.py create mode 100644 web-testbed/tests/tests_backend/proxies/expr_proxy.py create mode 100644 web-testbed/tests/tests_backend/proxies/non_widget_proxy.py create mode 100644 web-testbed/tests/tests_backend/proxies/widget_proxy.py diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 9da12bbe03..851095e617 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -5,12 +5,11 @@ from .tests_backend.playwright_page import BackgroundPage -# In future, would only need to be AppProxy, MainWindowProxy, ProxyBase and -# a SimpleProbe/BaseProbe. -# Possibly only ProxyBase and SimpleProbe/BaseProbe. +# In future, would only need to be ExprProxy and SimpleProbe/BaseProbe. from .tests_backend.proxies.app_proxy import AppProxy from .tests_backend.proxies.base_proxy import BaseProxy from .tests_backend.proxies.box_proxy import BoxProxy +from .tests_backend.proxies.expr_proxy import ExprProxy from .tests_backend.proxies.main_window_proxy import MainWindowProxy from .tests_backend.widgets.button import ButtonProbe @@ -29,6 +28,7 @@ def page(): def _wire_page(page): BaseProxy.page_provider = staticmethod(lambda: page) BoxProxy.page_provider = staticmethod(lambda: page) + ExprProxy.page_provider = staticmethod(lambda: page) MainWindowProxy.page_provider = staticmethod(lambda: page) ButtonProbe.page_provider = staticmethod(lambda: page) diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index 0026a177ba..ad4f30efd7 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -37,15 +37,22 @@ async def _bootstrap(self): self._context = await self._browser.new_context() self._page = await self._context.new_page() - await self._page.goto("http://localhost:8080/") - # await self._page.goto( - # "http://localhost:8080", wait_until="load", timeout=30_000 - # ) - await self._page.wait_for_timeout(7000) + # await self._page.goto("http://localhost:8080/") + await self._page.goto( + "http://localhost:8080", wait_until="load", timeout=30_000 + ) + # await self._page.wait_for_timeout(8000) + + await self._page.wait_for_function( + "() => typeof window.test_cmd === 'function'" + ) await self._page.evaluate( "(code) => window.test_cmd(code)", "self.my_widgets = {}" ) + await self._page.evaluate( + "(code) => window.test_cmd(code)", "self.my_objs = {}" + ) self._alock = asyncio.Lock() except Exception: diff --git a/web-testbed/tests/tests_backend/proxies/attribute_proxy.py b/web-testbed/tests/tests_backend/proxies/attribute_proxy.py new file mode 100644 index 0000000000..411f87e797 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/attribute_proxy.py @@ -0,0 +1,7 @@ +from .expr_proxy import ExprProxy + + +class AttributeProxy(ExprProxy): + def __init__(self, owner: ExprProxy, name: str): + ref_expr = f"getattr({owner.js_ref}, {repr(name)})" + super().__init__(ref_expr) diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index 489d88aa25..c98408271c 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -1,86 +1,28 @@ -class BaseProxy: +from .expr_proxy import ExprProxy + + +class BaseProxy(ExprProxy): + _storage_expr: str = "self.my_widgets" + page_provider = staticmethod(lambda: None) def _page(self): return type(self).page_provider() - def __init__(self, widget_id: str): - object.__setattr__(self, "_id", widget_id) + def __init__(self, object_key: str): + object.__setattr__(self, "_id", object_key) + ref_expr = f"{type(self)._storage_expr}[{repr(object_key)}]" + super().__init__(ref_expr) @property def id(self) -> str: return object.__getattribute__(self, "_id") - @property - def js_ref(self) -> str: - return f"self.my_widgets[{repr(self.id)}]" - @classmethod - def from_id(cls, widget_id: str) -> "BaseProxy": - return cls(widget_id) - - def _is_function(self, name: str) -> bool: - prop = repr(name) - code = ( - f"_obj = {self.js_ref}\n" - f"_attr = getattr(_obj, {prop})\n" - f"result = callable(_attr)" - ) - return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) - - def _encode_value(self, value) -> str: - # other proxy, pass by reference - if isinstance(value, BaseProxy): - return value.js_ref - # if plain primitives, embed as python literal - if isinstance(value, (str, int, float, bool)) or value is None: - return repr(value) - # everything else use text form (what Toga expects for .text, etc) - return repr(str(value)) - - def __setattr__(self, name, value): - prop = repr(name) - - if name.startswith("_"): - return object.__setattr__(self, name, value) - if name == "id": - raise AttributeError("Proxy 'id' is read-only") - - rhs = self._encode_value(value) - - code = f"setattr({self.js_ref}, {prop}, {rhs})" - self._page().eval_js("(code) => window.test_cmd(code)", code) - - def __getattr__(self, name): - prop = repr(name) - - # If it's a function on the remote side, return a Python wrapper - if self._is_function(name): - - def _method(*args): - parts = [self._encode_value(a) for a in args] - args_py = ", ".join(parts) - code = ( - f"_obj = {self.js_ref}\n" - f"_fn = getattr(_obj, {prop})\n" - f"result = _fn({args_py})" - ) - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - return _method - - # else plain property get - code = f"result = getattr({self.js_ref}, {prop})" - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - def add_to_main_window(self): - self._page().eval_js( - "(code) => window.test_cmd(code)", - f"self.main_window.content.add({self.js_ref})", - ) + def from_id(cls, object_key: str): + self = object.__new__(cls) + BaseProxy.__init__(self, object_key) + return self def __repr__(self): - return f"" - - def __str__(self): - return f"WidgetProxy({self.id})" + return f"<{type(self).__name__} id={self.id}>" diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py index 0e354bf4f3..7d903190d9 100644 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -33,3 +33,12 @@ def _create_remote_box(self): def add(self, widget): code = f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" self._page().eval_js("(code) => window.test_cmd(code)", code) + + """ + def add(self, widget): + code = ( + f"self.my_widgets['{self.id}'].add(self.widgets['{widget.id}'])\n" + f"self.my_widgets['{widget.id}'] = self.widgets['{widget.id}']" + ) + self._page().eval_js("(code) => window.test_cmd(code)", code) + """ diff --git a/web-testbed/tests/tests_backend/proxies/button_proxy.py b/web-testbed/tests/tests_backend/proxies/button_proxy.py index 0b85eca7dd..1128f21273 100644 --- a/web-testbed/tests/tests_backend/proxies/button_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/button_proxy.py @@ -1,12 +1,9 @@ -from .base_proxy import BaseProxy +from .widget_proxy import WidgetProxy -class ButtonProxy(BaseProxy): - def __init__(self, text="Hello"): - code = ( - f"new_widget = toga.Button({repr(text)})\n" - "self.my_widgets[new_widget.id] = new_widget\n" - "result = new_widget.id" - ) - wid = self._page().eval_js("(code) => window.test_cmd(code)", code) - super().__init__(wid) +class ButtonProxy(WidgetProxy): + _ctor_expr = "toga.Button" + + def __init__(self, *args, **kwargs): + key = self._create_with_known_id(self._ctor_expr, *args, **kwargs) + super().__init__(key) diff --git a/web-testbed/tests/tests_backend/proxies/expr_proxy.py b/web-testbed/tests/tests_backend/proxies/expr_proxy.py new file mode 100644 index 0000000000..34cb453fad --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/expr_proxy.py @@ -0,0 +1,91 @@ +class ExprProxy: + page_provider = staticmethod(lambda: None) + + def __init__(self, ref_expr: str): + object.__setattr__(self, "_ref_expr", ref_expr) + + def _page(self): + return type(self).page_provider() + + @property + def js_ref(self) -> str: + return object.__getattribute__(self, "_ref_expr") + + def _encode_value(self, value) -> str: + from .base_proxy import BaseProxy # local import to avoid cycle + + if isinstance(value, (ExprProxy, BaseProxy)): + return value.js_ref + if isinstance(value, (str, int, float, bool)) or value is None: + return repr(value) + if isinstance(value, (list, tuple)): + inner = ", ".join(self._encode_value(x) for x in value) + open_, close_ = ("[", "]") if isinstance(value, list) else ("(", ")") + return f"{open_}{inner}{close_}" + if isinstance(value, dict): + items = ", ".join( + f"{repr(k)}: {self._encode_value(v)}" for k, v in value.items() + ) + # return "{%s}" % items + return f"{{{items}}}" + return repr(str(value)) + + def _encode_call(self, *args, **kwargs) -> str: + parts = [self._encode_value(a) for a in args] + parts += [f"{k}={self._encode_value(v)}" for k, v in kwargs.items()] + return ", ".join(parts) + + def _is_function(self, name: str) -> bool: + prop = repr(name) + code = ( + f"_obj = {self.js_ref}\n" + f"_attr = getattr(_obj, {prop})\n" + f"result = callable(_attr)" + ) + return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) + + def _is_primitive_attr(self, name: str) -> bool: + prop = repr(name) + code = ( + f"_obj = {self.js_ref}\n" + f"_attr = getattr(_obj, {prop})\n" + "result = isinstance(_attr, (str, int, float, bool)) or _attr is None" + ) + return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) + + def __getattr__(self, name): + if self._is_function(name): + prop = repr(name) + + def _method(*args, **kwargs): + args_py = self._encode_call(*args, **kwargs) + code = ( + f"_obj = {self.js_ref}\n" + f"_fn = getattr(_obj, {prop})\n" + f"result = _fn({args_py})" + ) + return self._page().eval_js("(code) => window.test_cmd(code)", code) + + return _method + + if self._is_primitive_attr(name): + code = f"result = getattr({self.js_ref}, {repr(name)})" + return self._page().eval_js("(code) => window.test_cmd(code)", code) + + # Return another ExprProxy for attribute objects (ie toga.Button.style) + return ExprProxy(f"getattr({self.js_ref}, {repr(name)})") + + def __setattr__(self, name, value): + if name.startswith("_"): + return object.__setattr__(self, name, value) + code = f"setattr({self.js_ref}, {repr(name)}, {self._encode_value(value)})" + self._page().eval_js("(code) => window.test_cmd(code)", code) + + def __delattr__(self, name): + if name.startswith("_"): + return object.__delattr__(self, name) + code = f"delattr({self.js_ref}, {repr(name)})" + self._page().eval_js("(code) => window.test_cmd(code)", code) + + def __repr__(self): + return f"" diff --git a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py new file mode 100644 index 0000000000..d97bc5758e --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py @@ -0,0 +1,17 @@ +from .base_proxy import BaseProxy + + +class NonWidgetProxy(BaseProxy): + # _storage_expr = "self.my_objs" + # _ctor_expr: str | None = None + + def _create(self, ctor_expr: str, *args, **kwargs) -> str: + call_args = self._encode_call(*args, **kwargs) + code = ( + "import uuid\n" + f"new_obj = {ctor_expr}({call_args})\n" + "key = str(uuid.uuid4())\n" + "self.my_widgets[key] = new_obj\n" + "result = key" + ) + return self._page().eval_js("(code) => window.test_cmd(code)", code) diff --git a/web-testbed/tests/tests_backend/proxies/widget_proxy.py b/web-testbed/tests/tests_backend/proxies/widget_proxy.py new file mode 100644 index 0000000000..5852118a55 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/widget_proxy.py @@ -0,0 +1,21 @@ +from .base_proxy import BaseProxy + + +class WidgetProxy(BaseProxy): + # In-built widget register + # _storage_expr = "self.widgets" + + def _create_with_known_id(self, ctor_expr: str, *args, **kwargs) -> str: + call_args = self._encode_call(*args, **kwargs) + code = ( + f"new_widget = {ctor_expr}({call_args})\n" + "self.my_widgets[new_widget.id] = new_widget\n" + "result = new_widget.id" + ) + return self._page().eval_js("(code) => window.test_cmd(code)", code) + + def add_to_main_window(self): + self._page().eval_js( + "(code) => window.test_cmd(code)", + f"self.main_window.content.add({self.js_ref})", + ) diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py index 0502a087dc..cb6ff1e8f2 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -5,7 +5,7 @@ @fixture async def widget(): - return ButtonProxy() + return ButtonProxy("Hello") async def test_text(widget, probe): From 87ab6828ecb2e3269a3733f8bd24ee5eb45cbe42 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Sun, 14 Sep 2025 19:32:30 +0800 Subject: [PATCH 13/37] Last 2 button tests work. --- .../tests/tests_backend/proxies/box_proxy.py | 9 -- .../tests/tests_backend/proxies/mock_proxy.py | 9 ++ .../tests_backend/proxies/non_widget_proxy.py | 1 + .../tests_backend/proxies/widget_proxy.py | 1 + .../tests/tests_backend/widgets/button.py | 82 ++++++++++++++--- web-testbed/tests/widgets/test_button.py | 92 +++++++++++++++++++ 6 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 web-testbed/tests/tests_backend/proxies/mock_proxy.py diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py index 7d903190d9..0e354bf4f3 100644 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -33,12 +33,3 @@ def _create_remote_box(self): def add(self, widget): code = f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" self._page().eval_js("(code) => window.test_cmd(code)", code) - - """ - def add(self, widget): - code = ( - f"self.my_widgets['{self.id}'].add(self.widgets['{widget.id}'])\n" - f"self.my_widgets['{widget.id}'] = self.widgets['{widget.id}']" - ) - self._page().eval_js("(code) => window.test_cmd(code)", code) - """ diff --git a/web-testbed/tests/tests_backend/proxies/mock_proxy.py b/web-testbed/tests/tests_backend/proxies/mock_proxy.py new file mode 100644 index 0000000000..5113d9c2d1 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/mock_proxy.py @@ -0,0 +1,9 @@ +from .non_widget_proxy import NonWidgetProxy + + +class MockProxy(NonWidgetProxy): + _ctor_expr = "Mock" + + def __init__(self, *args, **kwargs): + key = self._create(self._ctor_expr, *args, **kwargs) + super().__init__(key) diff --git a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py index d97bc5758e..8dccf6f75b 100644 --- a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py @@ -2,6 +2,7 @@ class NonWidgetProxy(BaseProxy): + # Using my_widgets for all objects for now # _storage_expr = "self.my_objs" # _ctor_expr: str | None = None diff --git a/web-testbed/tests/tests_backend/proxies/widget_proxy.py b/web-testbed/tests/tests_backend/proxies/widget_proxy.py index 5852118a55..6242113a83 100644 --- a/web-testbed/tests/tests_backend/proxies/widget_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/widget_proxy.py @@ -3,6 +3,7 @@ class WidgetProxy(BaseProxy): # In-built widget register + # Using my_widgets for all objects for now # _storage_expr = "self.widgets" def _create_with_known_id(self, ctor_expr: str, *args, **kwargs) -> str: diff --git a/web-testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py index ee9b5c85e2..d6077e23f1 100644 --- a/web-testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -1,3 +1,41 @@ +import re + + +class _ColorLike: + __slots__ = ("r", "g", "b", "a") + + def __init__(self, r, g, b, a=1.0): + self.r = int(r) + self.g = int(g) + self.b = int(b) + self.a = float(a) + + def __repr__(self): + return f"_ColorLike(r={self.r}, g={self.g}, b={self.b}, a={self.a})" + + +_CSS_RGBA_RE = re.compile( + r"rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)(?:\s*[/,]\s*([0-9.]+))?\s*\)", + re.IGNORECASE, +) + + +def _parse_css_rgba(s: str) -> "_ColorLike | None | str": + if s is None: + return None + s = s.strip().lower() + # Treat literal 'transparent' as fully transparent black + if s == "transparent": + return _ColorLike(0, 0, 0, 0.0) + m = _CSS_RGBA_RE.match(s) + if not m: + # Unknown format, return as-is + return s + r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3)) + a = float(m.group(4)) if m.group(4) is not None else 1.0 + return _ColorLike(r, g, b, a) + + class ButtonProbe: page_provider = staticmethod(lambda: None) @@ -19,18 +57,32 @@ def height(self): box = page.run_coro(lambda p: p.locator(f"#{self.dom_id}").bounding_box()) return None if box is None else box["height"] - # 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) - """ + async def press(self): + page = self._page() + + # Click + page.run_coro(lambda p: p.locator(f"#{self.dom_id}").click()) + + # Yield to the event loop so on_press handler runs before assertions + # (wait_for_timeout(0) is a no-op tick in Playwright) + page.run_coro(lambda p: p.wait_for_timeout(0)) + + @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; // 'rgb(...)' or 'rgba(...)' + }""", + f"#{self.dom_id}", + ) + ) + return _parse_css_rgba(css) diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py index cb6ff1e8f2..a92fc16c76 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -1,6 +1,7 @@ from pytest import approx, fixture from tests.data import TEXTS from tests.tests_backend.proxies.button_proxy import ButtonProxy +from tests.tests_backend.proxies.mock_proxy import MockProxy @fixture @@ -25,3 +26,94 @@ async def test_text(widget, probe): 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 = MockProxy() + widget.on_press = handler + await probe.press() # Includes a no-op tick, not needed though + + # no-op + # await probe.redraw("Button should be pressed") + + handler.assert_called_once_with(widget) + + +TRANSPARENT = "transparent" + + +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) + + +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)), + ) From 666a2d2e4662ccfd8b3c0de8199e0de15eb1edf5 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Sun, 14 Sep 2025 20:58:26 +0800 Subject: [PATCH 14/37] Minor changes from recent feedback. Needs the 'return repr(str(value))' in '_encode_value' in 'expr_proxy.py', otherwise the 'MyObject()' data for the button 'test_text' does not work. --- web-testbed/tests/assertions.py | 64 ++++++++++++++++++ web-testbed/tests/conftest.py | 2 +- .../tests/tests_backend/playwright_page.py | 2 - .../tests/tests_backend/proxies/expr_proxy.py | 1 + web-testbed/tests/widgets/test_button.py | 67 +------------------ 5 files changed, 69 insertions(+), 67 deletions(-) create mode 100644 web-testbed/tests/assertions.py 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 index 851095e617..36eb89aa53 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -21,7 +21,7 @@ @pytest.fixture(scope="session") def page(): p = BackgroundPage() - yield p + return p @pytest.fixture(scope="session", autouse=True) diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index ad4f30efd7..6362fc85d1 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -6,8 +6,6 @@ class BackgroundPage: def __init__(self): - if getattr(self, "_init", False): - return self._init = True self._ready = threading.Event() self._loop = None diff --git a/web-testbed/tests/tests_backend/proxies/expr_proxy.py b/web-testbed/tests/tests_backend/proxies/expr_proxy.py index 34cb453fad..879154a556 100644 --- a/web-testbed/tests/tests_backend/proxies/expr_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/expr_proxy.py @@ -28,6 +28,7 @@ def _encode_value(self, value) -> str: ) # return "{%s}" % items return f"{{{items}}}" + return repr(str(value)) def _encode_call(self, *args, **kwargs) -> str: diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py index a92fc16c76..f8397685ac 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -1,8 +1,11 @@ from pytest import approx, fixture +from tests.assertions import assert_background_color from tests.data import TEXTS from tests.tests_backend.proxies.button_proxy import ButtonProxy from tests.tests_backend.proxies.mock_proxy import MockProxy +TRANSPARENT = "transparent" + @fixture async def widget(): @@ -45,9 +48,6 @@ async def test_press(widget, probe): handler.assert_called_once_with(widget) -TRANSPARENT = "transparent" - - async def test_background_color_transparent(widget, probe): "Buttons treat background transparency as a color reset." del widget.style.background_color @@ -56,64 +56,3 @@ async def test_background_color_transparent(widget, probe): 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) - - -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)), - ) From b403792ad92124fe56b0c6e9b10a29551b5a6d47 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Mon, 15 Sep 2025 11:09:22 +0800 Subject: [PATCH 15/37] Proxify BoxProxy. Non-widget objects use 'my_objs' dict. Tried integrating the in-built 'self.widgets' registry, did not go well, I have my suspicions why. Therefore will keep using 'my_widgets' for now. --- .../tests/tests_backend/playwright_page.py | 2 -- .../tests/tests_backend/proxies/box_proxy.py | 36 ++++++++----------- .../tests/tests_backend/proxies/expr_proxy.py | 1 - .../proxies/main_window_proxy.py | 20 +++++------ .../tests_backend/proxies/non_widget_proxy.py | 5 ++- web-testbed/tests/widgets/properties.py | 1 - 6 files changed, 26 insertions(+), 39 deletions(-) diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index 6362fc85d1..d6dac071b1 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -35,11 +35,9 @@ async def _bootstrap(self): self._context = await self._browser.new_context() self._page = await self._context.new_page() - # await self._page.goto("http://localhost:8080/") await self._page.goto( "http://localhost:8080", wait_until="load", timeout=30_000 ) - # await self._page.wait_for_timeout(8000) await self._page.wait_for_function( "() => typeof window.test_cmd === 'function'" diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py index 0e354bf4f3..cec9b04f41 100644 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -1,17 +1,12 @@ -class BoxProxy: - # Currently only for use in the 'probe' pytest fixture. +from .widget_proxy import WidgetProxy - """Proxy for toga.Box(children=[...]).""" - page_provider = staticmethod(lambda: None) +class BoxProxy(WidgetProxy): + _ctor_expr = "toga.Box" - def _page(self): - return type(self).page_provider() - - def __init__(self, children=None): - # Create box object remotely - self.id = self._create_remote_box() - # If there's children, add them + def __init__(self, children=None, *args, **kwargs): + key = self._create_with_known_id(self._ctor_expr, *args, **kwargs) + super().__init__(key) if children: for child in children: self.add(child) @@ -19,17 +14,14 @@ def __init__(self, children=None): @classmethod def _from_id(cls, box_id: str): obj = cls.__new__(cls) - object.__setattr__(obj, "id", box_id) + WidgetProxy.__init__(obj, box_id) return obj - def _create_remote_box(self): - code = ( - "new_box = toga.Box()\n" - "self.my_widgets[new_box.id] = new_box\n" - "result = new_box.id" - ) - return self._page().eval_js("(code) => window.test_cmd(code)", code) - def add(self, widget): - code = f"self.my_widgets['{self.id}'].add(self.my_widgets['{widget.id}'])" - self._page().eval_js("(code) => window.test_cmd(code)", code) + child_js = getattr(widget, "js_ref", None) + if child_js is None: + child_js = f"{type(self)._storage_expr}[{repr(widget)}]" + self._page().eval_js( + "(code) => window.test_cmd(code)", + f"{self.js_ref}.add({child_js})", + ) diff --git a/web-testbed/tests/tests_backend/proxies/expr_proxy.py b/web-testbed/tests/tests_backend/proxies/expr_proxy.py index 879154a556..1db5acb3fb 100644 --- a/web-testbed/tests/tests_backend/proxies/expr_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/expr_proxy.py @@ -26,7 +26,6 @@ def _encode_value(self, value) -> str: items = ", ".join( f"{repr(k)}: {self._encode_value(v)}" for k, v in value.items() ) - # return "{%s}" % items return f"{{{items}}}" return repr(str(value)) diff --git a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py index 3fd171797b..af4cc82e69 100644 --- a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py @@ -2,7 +2,7 @@ class MainWindowProxy: - """Minimal proxy that can get/set content. Content must be a BoxProxy.""" + # Minimal proxy that can get/set content. Content must be a BoxProxy. page_provider = staticmethod(lambda: None) @@ -11,15 +11,15 @@ def _page(self): @property def content(self): - code = "result = self.main_window.content.id" - box_id = self._page().eval_js("(code) => window.test_cmd(code)", code) - if box_id is None: - return BoxProxy() - proxy = BoxProxy.__new__(BoxProxy) - proxy.id = box_id - return proxy + box_id = self._page().eval_js( + "(code) => window.test_cmd(code)", + "result = self.main_window.content.id", + ) + return BoxProxy._from_id(box_id) @content.setter def content(self, box_proxy): - code = f"self.main_window.content = self.my_widgets['{box_proxy.id}']" - self._page().eval_js("(code) => window.test_cmd(code)", code) + self._page().eval_js( + "(code) => window.test_cmd(code)", + f"self.main_window.content = self.my_widgets[{repr(box_proxy.id)}]", + ) diff --git a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py index 8dccf6f75b..5c6a4b7566 100644 --- a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py @@ -2,8 +2,7 @@ class NonWidgetProxy(BaseProxy): - # Using my_widgets for all objects for now - # _storage_expr = "self.my_objs" + _storage_expr = "self.my_objs" # _ctor_expr: str | None = None def _create(self, ctor_expr: str, *args, **kwargs) -> str: @@ -12,7 +11,7 @@ def _create(self, ctor_expr: str, *args, **kwargs) -> str: "import uuid\n" f"new_obj = {ctor_expr}({call_args})\n" "key = str(uuid.uuid4())\n" - "self.my_widgets[key] = new_obj\n" + "self.my_objs[key] = new_obj\n" "result = key" ) return self._page().eval_js("(code) => window.test_cmd(code)", code) diff --git a/web-testbed/tests/widgets/properties.py b/web-testbed/tests/widgets/properties.py index bef08b843b..e69de29bb2 100644 --- a/web-testbed/tests/widgets/properties.py +++ b/web-testbed/tests/widgets/properties.py @@ -1 +0,0 @@ -# Maybe don't implement until got more widget-specific tests complete. From 7e89ee8390aea5cdccf50cc1b7b9899e78e2a0ef Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:03:30 +0800 Subject: [PATCH 16/37] Added serialize to test_cmd --- web-testbed/src/testbed/app.py | 111 ++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/web-testbed/src/testbed/app.py b/web-testbed/src/testbed/app.py index b24320ec11..d6355ae67e 100644 --- a/web-testbed/src/testbed/app.py +++ b/web-testbed/src/testbed/app.py @@ -1,38 +1,73 @@ -import toga -from toga.style import Pack -from toga.style.pack import COLUMN - -try: - import js -except ModuleNotFoundError: - js = None -try: - from pyodide.ffi import create_proxy -except ModuleNotFoundError: - pyodide = None - - -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") - - if js is not None: - js.window.test_cmd = create_proxy(self.cmd_test) - - 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 cmd_test(self, code): - local_vars = {} - try: - exec(code, {"self": self, "toga": toga}, local_vars) - return local_vars.get("result", "No result") - except Exception as e: - return f"Error: {e}" - - -def main(): - return HelloWorld() +import uuid + +import toga +from toga.style import Pack +from toga.style.pack import COLUMN + +try: + import js +except ModuleNotFoundError: + js = None +try: + from pyodide.ffi import create_proxy, to_js +except ModuleNotFoundError: + pyodide = None + to_js = None + + +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") + + if js is not None: + js.window.test_cmd = create_proxy(self.cmd_test) + + 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 _is_primitive(self, v): + return isinstance(v, (type(None), bool, int, float, str)) + + def _serialize(self, v): + if self._is_primitive(v): + return v + if isinstance(v, list): + return [self._serialize(x) for x in v] + if isinstance(v, dict): + return {k: self._serialize(x) for k, x in v.items()} + + wid = getattr(v, "id", None) + if wid is not None: + self.my_widgets[wid] = v + return { + "$t": "handle", + "ns": "widgets", + "class": type(v).__name__, + "id": wid, + } + + hid = f"h_{uuid.uuid4().hex[:10]}" + self.my_objs[hid] = v + return {"$t": "handle", "ns": "handles", "class": type(v).__name__, "id": hid} + + def cmd_test(self, code): + local_vars = {} + try: + exec(code, {"self": self, "toga": toga}, local_vars) + result = local_vars.get("result", None) + result = self._serialize(result) + + if js is not None and to_js is not None: + # dicts become plain JS Objects + result = to_js(result, dict_converter=js.Object.fromEntries) + + return result + except Exception as e: + return f"Error: {e}" + + +def main(): + return HelloWorld() From 214bee1f6a60a06ae2e6397c33464bf3c49e9180 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:04:50 +0800 Subject: [PATCH 17/37] Added unwrap and fix repr(str() issue to fit with the new format --- .../tests/tests_backend/proxies/expr_proxy.py | 210 ++++++++++-------- 1 file changed, 119 insertions(+), 91 deletions(-) diff --git a/web-testbed/tests/tests_backend/proxies/expr_proxy.py b/web-testbed/tests/tests_backend/proxies/expr_proxy.py index 1db5acb3fb..da64d7d51a 100644 --- a/web-testbed/tests/tests_backend/proxies/expr_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/expr_proxy.py @@ -1,91 +1,119 @@ -class ExprProxy: - page_provider = staticmethod(lambda: None) - - def __init__(self, ref_expr: str): - object.__setattr__(self, "_ref_expr", ref_expr) - - def _page(self): - return type(self).page_provider() - - @property - def js_ref(self) -> str: - return object.__getattribute__(self, "_ref_expr") - - def _encode_value(self, value) -> str: - from .base_proxy import BaseProxy # local import to avoid cycle - - if isinstance(value, (ExprProxy, BaseProxy)): - return value.js_ref - if isinstance(value, (str, int, float, bool)) or value is None: - return repr(value) - if isinstance(value, (list, tuple)): - inner = ", ".join(self._encode_value(x) for x in value) - open_, close_ = ("[", "]") if isinstance(value, list) else ("(", ")") - return f"{open_}{inner}{close_}" - if isinstance(value, dict): - items = ", ".join( - f"{repr(k)}: {self._encode_value(v)}" for k, v in value.items() - ) - return f"{{{items}}}" - - return repr(str(value)) - - def _encode_call(self, *args, **kwargs) -> str: - parts = [self._encode_value(a) for a in args] - parts += [f"{k}={self._encode_value(v)}" for k, v in kwargs.items()] - return ", ".join(parts) - - def _is_function(self, name: str) -> bool: - prop = repr(name) - code = ( - f"_obj = {self.js_ref}\n" - f"_attr = getattr(_obj, {prop})\n" - f"result = callable(_attr)" - ) - return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) - - def _is_primitive_attr(self, name: str) -> bool: - prop = repr(name) - code = ( - f"_obj = {self.js_ref}\n" - f"_attr = getattr(_obj, {prop})\n" - "result = isinstance(_attr, (str, int, float, bool)) or _attr is None" - ) - return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) - - def __getattr__(self, name): - if self._is_function(name): - prop = repr(name) - - def _method(*args, **kwargs): - args_py = self._encode_call(*args, **kwargs) - code = ( - f"_obj = {self.js_ref}\n" - f"_fn = getattr(_obj, {prop})\n" - f"result = _fn({args_py})" - ) - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - return _method - - if self._is_primitive_attr(name): - code = f"result = getattr({self.js_ref}, {repr(name)})" - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - # Return another ExprProxy for attribute objects (ie toga.Button.style) - return ExprProxy(f"getattr({self.js_ref}, {repr(name)})") - - def __setattr__(self, name, value): - if name.startswith("_"): - return object.__setattr__(self, name, value) - code = f"setattr({self.js_ref}, {repr(name)}, {self._encode_value(value)})" - self._page().eval_js("(code) => window.test_cmd(code)", code) - - def __delattr__(self, name): - if name.startswith("_"): - return object.__delattr__(self, name) - code = f"delattr({self.js_ref}, {repr(name)})" - self._page().eval_js("(code) => window.test_cmd(code)", code) - - def __repr__(self): - return f"" +class ExprProxy: + page_provider = staticmethod(lambda: None) + + def __init__(self, ref_expr: str): + object.__setattr__(self, "_ref_expr", ref_expr) + + def _page(self): + return type(self).page_provider() + + @property + def js_ref(self) -> str: + return object.__getattribute__(self, "_ref_expr") + + def proxy_from_handle(h: dict): + if h.get("$t") != "handle": + raise TypeError("not a handle") + key = h["id"] + if h.get("ns", "widgets") == "widgets": + from .widget_proxy import WidgetProxy + + return WidgetProxy.from_id(key) + else: + from .non_widget_proxy import NonWidgetProxy + + return NonWidgetProxy.from_id(key) + + def _unwrap(self, v): + if isinstance(v, dict) and v.get("$t") == "handle": + return type(self).proxy_from_handle(v) + # lists/dicts may contain nested handles + if isinstance(v, list): + return [self._unwrap(x) for x in v] + if isinstance(v, dict): + return {k: self._unwrap(x) for k, x in v.items()} + return v + + def _encode_value(self, value) -> str: + from .base_proxy import BaseProxy # local import to avoid cycle + + if isinstance(value, (ExprProxy, BaseProxy)): + return value.js_ref + if isinstance(value, (str, int, float, bool)) or value is None: + return repr(value) + if isinstance(value, (list, tuple)): + inner = ", ".join(self._encode_value(x) for x in value) + open_, close_ = ("[", "]") if isinstance(value, list) else ("(", ")") + return f"{open_}{inner}{close_}" + if isinstance(value, dict): + items = ", ".join( + f"{repr(k)}: {self._encode_value(v)}" for k, v in value.items() + ) + return f"{{{items}}}" + # raise error if not any of this, will need to implement in the future if needed + raise TypeError(f"Don't know how to encode {type(value).__name__}. ") + + def _encode_call(self, *args, **kwargs) -> str: + parts = [self._encode_value(a) for a in args] + parts += [f"{k}={self._encode_value(v)}" for k, v in kwargs.items()] + return ", ".join(parts) + + def _is_function(self, name: str) -> bool: + prop = repr(name) + code = ( + f"_obj = {self.js_ref}\n" + f"_attr = getattr(_obj, {prop})\n" + f"result = callable(_attr)" + ) + return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) + + def _is_primitive_attr(self, name: str) -> bool: + prop = repr(name) + code = ( + f"_obj = {self.js_ref}\n" + f"_attr = getattr(_obj, {prop})\n" + "result = isinstance(_attr, (str, int, float, bool)) or _attr is None" + ) + return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) + + def __getattr__(self, name): + if self._is_function(name): + prop = repr(name) + + def _method(*args, **kwargs): + args_py = self._encode_call(*args, **kwargs) + code = ( + f"_obj = {self.js_ref}\n" + f"_fn = getattr(_obj, {prop})\n" + f"result = _fn({args_py})" + ) + return self._page().eval_js("(code) => window.test_cmd(code)", code) + + return _method + + if self._is_primitive_attr(name): + code = f"result = getattr({self.js_ref}, {repr(name)})" + return self._page().eval_js("(code) => window.test_cmd(code)", code) + + # Return another ExprProxy for attribute objects (ie toga.Button.style) + return ExprProxy(f"getattr({self.js_ref}, {repr(name)})") + + def __setattr__(self, name, value): + if name.startswith("_"): + return object.__setattr__(self, name, value) + + if name == "text": + rhs = repr(str(value)) + else: + rhs = self._encode_value(value) + code = f"setattr({self.js_ref}, {repr(name)}, {rhs})" + self._page().eval_js("(code) => window.test_cmd(code)", code) + + def __delattr__(self, name): + if name.startswith("_"): + return object.__delattr__(self, name) + code = f"delattr({self.js_ref}, {repr(name)})" + self._page().eval_js("(code) => window.test_cmd(code)", code) + + def __repr__(self): + return f"" From 61e365c852f4d0b6b2799fe0cf9d1a4410e09ff5 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:22:24 +0800 Subject: [PATCH 18/37] Removed BoxProxy from wire_page as unneeded --- web-testbed/tests/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 36eb89aa53..c35fe86dbd 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -8,7 +8,6 @@ # In future, would only need to be ExprProxy and SimpleProbe/BaseProbe. from .tests_backend.proxies.app_proxy import AppProxy from .tests_backend.proxies.base_proxy import BaseProxy -from .tests_backend.proxies.box_proxy import BoxProxy from .tests_backend.proxies.expr_proxy import ExprProxy from .tests_backend.proxies.main_window_proxy import MainWindowProxy from .tests_backend.widgets.button import ButtonProbe @@ -27,7 +26,6 @@ def page(): @pytest.fixture(scope="session", autouse=True) def _wire_page(page): BaseProxy.page_provider = staticmethod(lambda: page) - BoxProxy.page_provider = staticmethod(lambda: page) ExprProxy.page_provider = staticmethod(lambda: page) MainWindowProxy.page_provider = staticmethod(lambda: page) ButtonProbe.page_provider = staticmethod(lambda: page) From 14ab27f11c1cb75c280d8a303f455275cfb75d36 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:03:21 +0800 Subject: [PATCH 19/37] Added back the '[dependency-groups]' header --- web-testbed/pyproject.toml | 98 +++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/web-testbed/pyproject.toml b/web-testbed/pyproject.toml index 46a60dfcd5..6826821b1c 100644 --- a/web-testbed/pyproject.toml +++ b/web-testbed/pyproject.toml @@ -1,49 +1,49 @@ -[project] -name = "testbed" -version = "0.0.1" - -[project.optional-dependencies] -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" +[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" From b5fb51f1a789704de9e4946b6e64ac2082140b74 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Fri, 19 Sep 2025 16:17:54 +0800 Subject: [PATCH 20/37] Restructure of proxy architecure both on app and test suite side. Merging of some proxies. Several other smaller modifications --- web-testbed/pyproject.toml | 2 +- web-testbed/src/testbed/app.py | 71 ++++++- web-testbed/tests/conftest.py | 15 +- .../tests/tests_backend/playwright_page.py | 7 - .../tests/tests_backend/proxies/app_proxy.py | 11 +- .../tests_backend/proxies/attribute_proxy.py | 7 - .../tests/tests_backend/proxies/base_proxy.py | 181 ++++++++++++++++-- .../tests/tests_backend/proxies/box_proxy.py | 22 +-- .../tests_backend/proxies/button_proxy.py | 8 +- .../tests/tests_backend/proxies/encoding.py | 35 ++++ .../tests/tests_backend/proxies/expr_proxy.py | 91 --------- .../proxies/main_window_proxy.py | 25 --- .../tests/tests_backend/proxies/mock_proxy.py | 8 +- .../tests_backend/proxies/non_widget_proxy.py | 17 -- .../tests_backend/proxies/object_proxy.py | 31 +++ .../tests_backend/proxies/widget_proxy.py | 22 --- .../tests/tests_backend/widgets/button.py | 16 +- web-testbed/tests/widgets/test_button.py | 10 +- 18 files changed, 323 insertions(+), 256 deletions(-) delete mode 100644 web-testbed/tests/tests_backend/proxies/attribute_proxy.py create mode 100644 web-testbed/tests/tests_backend/proxies/encoding.py delete mode 100644 web-testbed/tests/tests_backend/proxies/expr_proxy.py delete mode 100644 web-testbed/tests/tests_backend/proxies/main_window_proxy.py delete mode 100644 web-testbed/tests/tests_backend/proxies/non_widget_proxy.py create mode 100644 web-testbed/tests/tests_backend/proxies/object_proxy.py delete mode 100644 web-testbed/tests/tests_backend/proxies/widget_proxy.py diff --git a/web-testbed/pyproject.toml b/web-testbed/pyproject.toml index 46a60dfcd5..9d12f312ea 100644 --- a/web-testbed/pyproject.toml +++ b/web-testbed/pyproject.toml @@ -2,7 +2,7 @@ name = "testbed" version = "0.0.1" -[project.optional-dependencies] +[dependency-groups] test = [ "briefcase", "playwright == 1.51.0", diff --git a/web-testbed/src/testbed/app.py b/web-testbed/src/testbed/app.py index b24320ec11..459bb4704d 100644 --- a/web-testbed/src/testbed/app.py +++ b/web-testbed/src/testbed/app.py @@ -1,3 +1,6 @@ +import types +from unittest.mock import Mock + import toga from toga.style import Pack from toga.style.pack import COLUMN @@ -7,7 +10,7 @@ except ModuleNotFoundError: js = None try: - from pyodide.ffi import create_proxy + from pyodide.ffi import create_proxy, to_js except ModuleNotFoundError: pyodide = None @@ -18,6 +21,7 @@ def startup(self): self.label = toga.Label(id="myLabel", text="Test App - Toga Web Testing") if js is not None: + self.my_objs = {} js.window.test_cmd = create_proxy(self.cmd_test) main_box.add(self.label) @@ -26,12 +30,69 @@ def startup(self): self.main_window.show() def cmd_test(self, code): - local_vars = {} + env = {"self": self, "toga": toga, "my_objs": self.my_objs, "Mock": Mock} + local = {} try: - exec(code, {"self": self, "toga": toga}, local_vars) - return local_vars.get("result", "No result") + exec(code, env, local) + result = local.get("result", env.get("result")) + envelope = self._serialize(result) + return to_js(envelope, dict_converter=js.Object.fromEntries) except Exception as e: - return f"Error: {e}" + return to_js( + {"type": "error", "value": str(e)}, dict_converter=js.Object.fromEntries + ) + + def _serialize(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._serialize(i) for i in x]} + if isinstance(x, tuple): + return {"type": "tuple", "items": [self._serialize(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._serialize(v)]) + return {"type": "dict", "items": items} + + # 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 main(): diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 36eb89aa53..a8accf2099 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -4,32 +4,23 @@ import pytest from .tests_backend.playwright_page import BackgroundPage - -# In future, would only need to be ExprProxy and SimpleProbe/BaseProbe. from .tests_backend.proxies.app_proxy import AppProxy + +# In future, would only need to be BaseProxy and SimpleProbe/BaseProbe. from .tests_backend.proxies.base_proxy import BaseProxy -from .tests_backend.proxies.box_proxy import BoxProxy -from .tests_backend.proxies.expr_proxy import ExprProxy -from .tests_backend.proxies.main_window_proxy import MainWindowProxy from .tests_backend.widgets.button import ButtonProbe -# With this page injection method, we could possibly extend so that -# multiple pages can be created and be running at once (if it is ever -# needed in the future). Would need to add a method/fixture to store -# and switch between them. @pytest.fixture(scope="session") def page(): p = BackgroundPage() return p +# In future, will only be BaseProxy and BaseProbe/SimpleProbe @pytest.fixture(scope="session", autouse=True) def _wire_page(page): BaseProxy.page_provider = staticmethod(lambda: page) - BoxProxy.page_provider = staticmethod(lambda: page) - ExprProxy.page_provider = staticmethod(lambda: page) - MainWindowProxy.page_provider = staticmethod(lambda: page) ButtonProbe.page_provider = staticmethod(lambda: page) diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index d6dac071b1..a2ec0cbf21 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -43,13 +43,6 @@ async def _bootstrap(self): "() => typeof window.test_cmd === 'function'" ) - await self._page.evaluate( - "(code) => window.test_cmd(code)", "self.my_widgets = {}" - ) - await self._page.evaluate( - "(code) => window.test_cmd(code)", "self.my_objs = {}" - ) - self._alock = asyncio.Lock() except Exception: raise diff --git a/web-testbed/tests/tests_backend/proxies/app_proxy.py b/web-testbed/tests/tests_backend/proxies/app_proxy.py index 6b4c9ccd13..6f7f1cb5e8 100644 --- a/web-testbed/tests/tests_backend/proxies/app_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/app_proxy.py @@ -1,9 +1,6 @@ -from .main_window_proxy import MainWindowProxy +from .base_proxy import BaseProxy -class AppProxy: - """Minimal app proxy: only expose main_window.""" - - @property - def main_window(self): - return MainWindowProxy() +class AppProxy(BaseProxy): + def __init__(self): + super().__init__("self") diff --git a/web-testbed/tests/tests_backend/proxies/attribute_proxy.py b/web-testbed/tests/tests_backend/proxies/attribute_proxy.py deleted file mode 100644 index 411f87e797..0000000000 --- a/web-testbed/tests/tests_backend/proxies/attribute_proxy.py +++ /dev/null @@ -1,7 +0,0 @@ -from .expr_proxy import ExprProxy - - -class AttributeProxy(ExprProxy): - def __init__(self, owner: ExprProxy, name: str): - ref_expr = f"getattr({owner.js_ref}, {repr(name)})" - super().__init__(ref_expr) diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index c98408271c..9092796483 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -1,28 +1,173 @@ -from .expr_proxy import ExprProxy +from .encoding import encode_value -class BaseProxy(ExprProxy): - _storage_expr: str = "self.my_widgets" +class ProxyProtocolError(RuntimeError): + # Raised when the remote bridge returns an invalid or unexpected payload. + pass - page_provider = staticmethod(lambda: None) - def _page(self): - return type(self).page_provider() +class BaseProxy: + # Remote pure expression proxy + # Attribute reads auto-realise primitives/containers, everything else stays proxied. + + _storage_expr = "my_objs" + + page_provider = staticmethod(lambda: None) - def __init__(self, object_key: str): - object.__setattr__(self, "_id", object_key) - ref_expr = f"{type(self)._storage_expr}[{repr(object_key)}]" - super().__init__(ref_expr) + def __init__(self, js_ref: str): + self._js_ref = js_ref @property - def id(self) -> str: - return object.__getattribute__(self, "_id") + def js_ref(self) -> str: + return self._js_ref @classmethod - def from_id(cls, object_key: str): - self = object.__new__(cls) - BaseProxy.__init__(self, object_key) - return self + def _page(cls): + return cls.page_provider() + + # Core methods + def __getattr__(self, name: str): + attr_expr = AttributeProxy(self, name) + ok, value = self._try_realise_value(attr_expr.js_ref) + return value if ok else attr_expr + + def __setattr__(self, name: str, value): + if name.startswith("_"): + return super().__setattr__(name, value) + code = f"setattr({self.js_ref}, {repr(name)}, {encode_value(value)})" + self._eval_and_return(code) + + def __delattr__(self, name: str): + if name.startswith("_"): + if hasattr(self, name): + return super().__delattr__(name) + raise AttributeError(name) + code = f"delattr({self.js_ref}, {repr(name)})" + self._eval_and_return(code) + + def __call__(self, *args, **kwargs): + parts = [] + if args: + parts += [encode_value(a) for a in args] + if kwargs: + parts += [f"{k}={encode_value(v)}" for k, v in kwargs.items()] + expr = f"{self.js_ref}({', '.join(parts)})" + return self._eval_and_return(expr) + + # 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 __repr__(self): - return f"<{type(self).__name__} id={self.id}>" + 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._decode_payload(payload) + + def _try_realise_value(self, expr_src: str): + # Used by __getattr__, try to get a concrete value for primitives/containers. + # Returns (True, value) for str/int/float/bool/None, + # list/tuple/dict; else (False, None). + try: + val = self._eval_and_return(expr_src) + except Exception: + return False, None + if ( + isinstance(val, (str, int, float, bool)) + or val is None + or isinstance(val, (list, tuple, dict)) + ): + return True, val + return False, None + + # Decode payload + def _decode_payload(self, payload): + # Decode 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._decode_payload(item) for item in payload.get("items", [])] + if t == "tuple": + return tuple( + self._decode_payload(item) for item in payload.get("items", []) + ) + if t == "dict": + out = {} + for k_env, v_env in payload.get("items", []): + k = self._decode_payload(k_env) + v = self._decode_payload(v_env) + out[k] = v + return out + + # references + if t in ("object", "callable"): + obj_id = payload["id"] + return BaseProxy(f"{self._storage_expr}[{repr(obj_id)}]") + + raise ProxyProtocolError(f"Unknown payload type: {t!r}") + + +# In this file to avoid circular imports +class AttributeProxy(BaseProxy): + def __init__(self, owner: "BaseProxy", name: str): + self._js_ref = f"getattr({owner.js_ref}, {repr(name)})" + + @property + def js_ref(self) -> str: + return self._js_ref diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py index cec9b04f41..ca0c71245d 100644 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/box_proxy.py @@ -1,27 +1,11 @@ -from .widget_proxy import WidgetProxy +from .object_proxy import ObjectProxy -class BoxProxy(WidgetProxy): +class BoxProxy(ObjectProxy): _ctor_expr = "toga.Box" def __init__(self, children=None, *args, **kwargs): - key = self._create_with_known_id(self._ctor_expr, *args, **kwargs) - super().__init__(key) + super().__init__(*args, **kwargs) if children: for child in children: self.add(child) - - @classmethod - def _from_id(cls, box_id: str): - obj = cls.__new__(cls) - WidgetProxy.__init__(obj, box_id) - return obj - - def add(self, widget): - child_js = getattr(widget, "js_ref", None) - if child_js is None: - child_js = f"{type(self)._storage_expr}[{repr(widget)}]" - self._page().eval_js( - "(code) => window.test_cmd(code)", - f"{self.js_ref}.add({child_js})", - ) diff --git a/web-testbed/tests/tests_backend/proxies/button_proxy.py b/web-testbed/tests/tests_backend/proxies/button_proxy.py index 1128f21273..ad40431d8b 100644 --- a/web-testbed/tests/tests_backend/proxies/button_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/button_proxy.py @@ -1,9 +1,5 @@ -from .widget_proxy import WidgetProxy +from .object_proxy import ObjectProxy -class ButtonProxy(WidgetProxy): +class ButtonProxy(ObjectProxy): _ctor_expr = "toga.Button" - - def __init__(self, *args, **kwargs): - key = self._create_with_known_id(self._ctor_expr, *args, **kwargs) - super().__init__(key) diff --git a/web-testbed/tests/tests_backend/proxies/encoding.py b/web-testbed/tests/tests_backend/proxies/encoding.py new file mode 100644 index 0000000000..0e900d1b12 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/encoding.py @@ -0,0 +1,35 @@ +def encode_value(v) -> str: + # Encode a Python value or proxy-ish object. + # Supported: + # - proxies (via .js_ref) + # - primitives (str/int/float/bool/None) + # - list/tuple (recursive) + # - dict with primitive keys (recursive on values) + if hasattr(v, "js_ref"): # any proxy + return v.js_ref + + if isinstance(v, (str, int, float, bool)) or v is None: + return repr(v) + + if isinstance(v, list): + inner = ", ".join(encode_value(x) for x in v) + return f"[{inner}]" + + if isinstance(v, tuple): + inner = ", ".join(encode_value(x) for x in v) + if len(v) == 1: + inner += "," + return f"({inner})" + + if isinstance(v, dict): + items = ", ".join(f"{repr(k)}: {encode_value(val)}" for k, val in v.items()) + return f"{{{items}}}" + + # In toga, setting 'widget.text = object' implicitly uses str(object) + try: + return repr(str(v)) + except Exception as e: + raise TypeError( + f"Cannot encode {type(v).__name__}; pass a proxy (.js_ref), " + f"primitive, list/tuple, dict, or an object with a valid __str__()." + ) from e diff --git a/web-testbed/tests/tests_backend/proxies/expr_proxy.py b/web-testbed/tests/tests_backend/proxies/expr_proxy.py deleted file mode 100644 index 1db5acb3fb..0000000000 --- a/web-testbed/tests/tests_backend/proxies/expr_proxy.py +++ /dev/null @@ -1,91 +0,0 @@ -class ExprProxy: - page_provider = staticmethod(lambda: None) - - def __init__(self, ref_expr: str): - object.__setattr__(self, "_ref_expr", ref_expr) - - def _page(self): - return type(self).page_provider() - - @property - def js_ref(self) -> str: - return object.__getattribute__(self, "_ref_expr") - - def _encode_value(self, value) -> str: - from .base_proxy import BaseProxy # local import to avoid cycle - - if isinstance(value, (ExprProxy, BaseProxy)): - return value.js_ref - if isinstance(value, (str, int, float, bool)) or value is None: - return repr(value) - if isinstance(value, (list, tuple)): - inner = ", ".join(self._encode_value(x) for x in value) - open_, close_ = ("[", "]") if isinstance(value, list) else ("(", ")") - return f"{open_}{inner}{close_}" - if isinstance(value, dict): - items = ", ".join( - f"{repr(k)}: {self._encode_value(v)}" for k, v in value.items() - ) - return f"{{{items}}}" - - return repr(str(value)) - - def _encode_call(self, *args, **kwargs) -> str: - parts = [self._encode_value(a) for a in args] - parts += [f"{k}={self._encode_value(v)}" for k, v in kwargs.items()] - return ", ".join(parts) - - def _is_function(self, name: str) -> bool: - prop = repr(name) - code = ( - f"_obj = {self.js_ref}\n" - f"_attr = getattr(_obj, {prop})\n" - f"result = callable(_attr)" - ) - return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) - - def _is_primitive_attr(self, name: str) -> bool: - prop = repr(name) - code = ( - f"_obj = {self.js_ref}\n" - f"_attr = getattr(_obj, {prop})\n" - "result = isinstance(_attr, (str, int, float, bool)) or _attr is None" - ) - return bool(self._page().eval_js("(code) => window.test_cmd(code)", code)) - - def __getattr__(self, name): - if self._is_function(name): - prop = repr(name) - - def _method(*args, **kwargs): - args_py = self._encode_call(*args, **kwargs) - code = ( - f"_obj = {self.js_ref}\n" - f"_fn = getattr(_obj, {prop})\n" - f"result = _fn({args_py})" - ) - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - return _method - - if self._is_primitive_attr(name): - code = f"result = getattr({self.js_ref}, {repr(name)})" - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - # Return another ExprProxy for attribute objects (ie toga.Button.style) - return ExprProxy(f"getattr({self.js_ref}, {repr(name)})") - - def __setattr__(self, name, value): - if name.startswith("_"): - return object.__setattr__(self, name, value) - code = f"setattr({self.js_ref}, {repr(name)}, {self._encode_value(value)})" - self._page().eval_js("(code) => window.test_cmd(code)", code) - - def __delattr__(self, name): - if name.startswith("_"): - return object.__delattr__(self, name) - code = f"delattr({self.js_ref}, {repr(name)})" - self._page().eval_js("(code) => window.test_cmd(code)", code) - - def __repr__(self): - return f"" diff --git a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py b/web-testbed/tests/tests_backend/proxies/main_window_proxy.py deleted file mode 100644 index af4cc82e69..0000000000 --- a/web-testbed/tests/tests_backend/proxies/main_window_proxy.py +++ /dev/null @@ -1,25 +0,0 @@ -from .box_proxy import BoxProxy - - -class MainWindowProxy: - # Minimal proxy that can get/set content. Content must be a BoxProxy. - - page_provider = staticmethod(lambda: None) - - def _page(self): - return type(self).page_provider() - - @property - def content(self): - box_id = self._page().eval_js( - "(code) => window.test_cmd(code)", - "result = self.main_window.content.id", - ) - return BoxProxy._from_id(box_id) - - @content.setter - def content(self, box_proxy): - self._page().eval_js( - "(code) => window.test_cmd(code)", - f"self.main_window.content = self.my_widgets[{repr(box_proxy.id)}]", - ) diff --git a/web-testbed/tests/tests_backend/proxies/mock_proxy.py b/web-testbed/tests/tests_backend/proxies/mock_proxy.py index 5113d9c2d1..f2e5106340 100644 --- a/web-testbed/tests/tests_backend/proxies/mock_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/mock_proxy.py @@ -1,9 +1,5 @@ -from .non_widget_proxy import NonWidgetProxy +from .object_proxy import ObjectProxy -class MockProxy(NonWidgetProxy): +class MockProxy(ObjectProxy): _ctor_expr = "Mock" - - def __init__(self, *args, **kwargs): - key = self._create(self._ctor_expr, *args, **kwargs) - super().__init__(key) diff --git a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py b/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py deleted file mode 100644 index 5c6a4b7566..0000000000 --- a/web-testbed/tests/tests_backend/proxies/non_widget_proxy.py +++ /dev/null @@ -1,17 +0,0 @@ -from .base_proxy import BaseProxy - - -class NonWidgetProxy(BaseProxy): - _storage_expr = "self.my_objs" - # _ctor_expr: str | None = None - - def _create(self, ctor_expr: str, *args, **kwargs) -> str: - call_args = self._encode_call(*args, **kwargs) - code = ( - "import uuid\n" - f"new_obj = {ctor_expr}({call_args})\n" - "key = str(uuid.uuid4())\n" - "self.my_objs[key] = new_obj\n" - "result = key" - ) - return self._page().eval_js("(code) => window.test_cmd(code)", code) 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..b493d67c1f --- /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"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/proxies/widget_proxy.py b/web-testbed/tests/tests_backend/proxies/widget_proxy.py deleted file mode 100644 index 6242113a83..0000000000 --- a/web-testbed/tests/tests_backend/proxies/widget_proxy.py +++ /dev/null @@ -1,22 +0,0 @@ -from .base_proxy import BaseProxy - - -class WidgetProxy(BaseProxy): - # In-built widget register - # Using my_widgets for all objects for now - # _storage_expr = "self.widgets" - - def _create_with_known_id(self, ctor_expr: str, *args, **kwargs) -> str: - call_args = self._encode_call(*args, **kwargs) - code = ( - f"new_widget = {ctor_expr}({call_args})\n" - "self.my_widgets[new_widget.id] = new_widget\n" - "result = new_widget.id" - ) - return self._page().eval_js("(code) => window.test_cmd(code)", code) - - def add_to_main_window(self): - self._page().eval_js( - "(code) => window.test_cmd(code)", - f"self.main_window.content.add({self.js_ref})", - ) diff --git a/web-testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py index d6077e23f1..fe510850e0 100644 --- a/web-testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -43,8 +43,8 @@ def _page(self): return type(self).page_provider() def __init__(self, widget): - object.__setattr__(self, "id", widget.id) - object.__setattr__(self, "dom_id", f"toga_{widget.id}") + self.id = widget.id + self.dom_id = f"toga_{widget.id}" @property def text(self): @@ -60,19 +60,21 @@ def height(self): async def press(self): page = self._page() - # Click + # Click/press page.run_coro(lambda p: p.locator(f"#{self.dom_id}").click()) + async def redraw(self, text): + 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) page.run_coro(lambda p: p.wait_for_timeout(0)) @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. - """ + # 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( diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py index f8397685ac..6d9415505e 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -19,8 +19,7 @@ async def test_text(widget, probe): for text in TEXTS: widget.text = text - # no-op - # await probe.redraw(f"Button text should be {text}") + await probe.redraw(f"Button text should be {text}") # Text after a newline will be stripped. assert isinstance(widget.text, str) @@ -40,10 +39,9 @@ async def test_press(widget, probe): # to one in the remote web app. handler = MockProxy() widget.on_press = handler - await probe.press() # Includes a no-op tick, not needed though + await probe.press() - # no-op - # await probe.redraw("Button should be pressed") + await probe.redraw("Button should be pressed") handler.assert_called_once_with(widget) @@ -54,5 +52,5 @@ async def test_background_color_transparent(widget, probe): original_background_color = probe.background_color widget.style.background_color = TRANSPARENT - # await probe.redraw("Button background color should be reset to the default color") + await probe.redraw("Button background color should be reset to the default color") assert_background_color(probe.background_color, original_background_color) From 5c4f4ecef5d307dedda8a9a5c78513736a9b1e16 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Sun, 21 Sep 2025 14:32:01 +0800 Subject: [PATCH 21/37] Put all proxy definitions in 'object_proxies'. Probe structure changes. Minor variable renaming and other changes. --- web-testbed/src/testbed/app.py | 10 +++++----- web-testbed/tests/conftest.py | 10 +++------- .../tests/tests_backend/proxies/base_proxy.py | 6 +++--- .../tests/tests_backend/proxies/box_proxy.py | 11 ---------- .../tests_backend/proxies/button_proxy.py | 5 ----- .../tests/tests_backend/proxies/mock_proxy.py | 5 ----- .../tests_backend/proxies/object_proxies.py | 13 ++++++++++++ .../tests/tests_backend/widgets/base.py | 18 ++++++++++++++++- .../tests/tests_backend/widgets/button.py | 20 +++---------------- web-testbed/tests/widgets/conftest.py | 2 +- web-testbed/tests/widgets/test_button.py | 3 +-- 11 files changed, 46 insertions(+), 57 deletions(-) delete mode 100644 web-testbed/tests/tests_backend/proxies/box_proxy.py delete mode 100644 web-testbed/tests/tests_backend/proxies/button_proxy.py delete mode 100644 web-testbed/tests/tests_backend/proxies/mock_proxy.py create mode 100644 web-testbed/tests/tests_backend/proxies/object_proxies.py diff --git a/web-testbed/src/testbed/app.py b/web-testbed/src/testbed/app.py index 459bb4704d..9a3262c5da 100644 --- a/web-testbed/src/testbed/app.py +++ b/web-testbed/src/testbed/app.py @@ -35,14 +35,14 @@ def cmd_test(self, code): try: exec(code, env, local) result = local.get("result", env.get("result")) - envelope = self._serialize(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 _serialize(self, x): + def _serialise_payload(self, x): # primitives if x is None: return {"type": "none", "value": None} @@ -57,9 +57,9 @@ def _serialize(self, x): # containers if isinstance(x, list): - return {"type": "list", "items": [self._serialize(i) for i in x]} + return {"type": "list", "items": [self._serialise_payload(i) for i in x]} if isinstance(x, tuple): - return {"type": "tuple", "items": [self._serialize(i) for i in x]} + return {"type": "tuple", "items": [self._serialise_payload(i) for i in x]} if isinstance(x, dict): items = [] for k, v in x.items(): @@ -75,7 +75,7 @@ def _serialize(self, x): key_env = {"type": "str", "value": k} else: key_env = {"type": "str", "value": str(k)} - items.append([key_env, self._serialize(v)]) + items.append([key_env, self._serialise_payload(v)]) return {"type": "dict", "items": items} # references by id diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index a8accf2099..1b6226d056 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -5,10 +5,8 @@ from .tests_backend.playwright_page import BackgroundPage from .tests_backend.proxies.app_proxy import AppProxy - -# In future, would only need to be BaseProxy and SimpleProbe/BaseProbe. from .tests_backend.proxies.base_proxy import BaseProxy -from .tests_backend.widgets.button import ButtonProbe +from .tests_backend.widgets.base import SimpleProbe @pytest.fixture(scope="session") @@ -17,20 +15,18 @@ def page(): return p -# In future, will only be BaseProxy and BaseProbe/SimpleProbe +# Inject Playwright page object into @pytest.fixture(scope="session", autouse=True) def _wire_page(page): BaseProxy.page_provider = staticmethod(lambda: page) - ButtonProbe.page_provider = staticmethod(lambda: page) + SimpleProbe.page_provider = staticmethod(lambda: page) @pytest.fixture(scope="session") def app(): - # just return AppProxy return AppProxy() @pytest.fixture(scope="session") def main_window(app): - # return main window created by app proxy return app.main_window diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index 9092796483..38c8b04081 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -99,7 +99,7 @@ def _eval_and_return(self, expr_src: str): payload = page.eval_js( "(code) => window.test_cmd(code)", f"result = {expr_src}" ) - return self._decode_payload(payload) + return self._deserialise_payload(payload) def _try_realise_value(self, expr_src: str): # Used by __getattr__, try to get a concrete value for primitives/containers. @@ -118,8 +118,8 @@ def _try_realise_value(self, expr_src: str): return False, None # Decode payload - def _decode_payload(self, payload): - # Decode strict typed envelopes: + def _deserialise_payload(self, payload): + # Des-serialise strict typed envelopes: # - none/bool/int/float/str # - list/tuple/dict (recursive) # - object/callable -> proxy reference (my_objs[id]) diff --git a/web-testbed/tests/tests_backend/proxies/box_proxy.py b/web-testbed/tests/tests_backend/proxies/box_proxy.py deleted file mode 100644 index ca0c71245d..0000000000 --- a/web-testbed/tests/tests_backend/proxies/box_proxy.py +++ /dev/null @@ -1,11 +0,0 @@ -from .object_proxy import ObjectProxy - - -class BoxProxy(ObjectProxy): - _ctor_expr = "toga.Box" - - def __init__(self, children=None, *args, **kwargs): - super().__init__(*args, **kwargs) - if children: - for child in children: - self.add(child) diff --git a/web-testbed/tests/tests_backend/proxies/button_proxy.py b/web-testbed/tests/tests_backend/proxies/button_proxy.py deleted file mode 100644 index ad40431d8b..0000000000 --- a/web-testbed/tests/tests_backend/proxies/button_proxy.py +++ /dev/null @@ -1,5 +0,0 @@ -from .object_proxy import ObjectProxy - - -class ButtonProxy(ObjectProxy): - _ctor_expr = "toga.Button" diff --git a/web-testbed/tests/tests_backend/proxies/mock_proxy.py b/web-testbed/tests/tests_backend/proxies/mock_proxy.py deleted file mode 100644 index f2e5106340..0000000000 --- a/web-testbed/tests/tests_backend/proxies/mock_proxy.py +++ /dev/null @@ -1,5 +0,0 @@ -from .object_proxy import ObjectProxy - - -class MockProxy(ObjectProxy): - _ctor_expr = "Mock" 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..07a92cada0 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -0,0 +1,13 @@ +from .object_proxy import ObjectProxy + + +class BoxProxy(ObjectProxy): + _ctor_expr = "toga.Box" + + +class ButtonProxy(ObjectProxy): + _ctor_expr = "toga.Button" + + +class MockProxy(ObjectProxy): + _ctor_expr = "Mock" diff --git a/web-testbed/tests/tests_backend/widgets/base.py b/web-testbed/tests/tests_backend/widgets/base.py index f5c05341a9..8c6806c243 100644 --- a/web-testbed/tests/tests_backend/widgets/base.py +++ b/web-testbed/tests/tests_backend/widgets/base.py @@ -1 +1,17 @@ -# SimpleProbe, same with BaseProbe, maybe don't implement until later. +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 index fe510850e0..c16dd434c5 100644 --- a/web-testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -1,5 +1,7 @@ import re +from .base import SimpleProbe + class _ColorLike: __slots__ = ("r", "g", "b", "a") @@ -36,16 +38,7 @@ def _parse_css_rgba(s: str) -> "_ColorLike | None | str": return _ColorLike(r, g, b, a) -class ButtonProbe: - 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}" - +class ButtonProbe(SimpleProbe): @property def text(self): page = self._page() @@ -63,13 +56,6 @@ async def press(self): # Click/press page.run_coro(lambda p: p.locator(f"#{self.dom_id}").click()) - async def redraw(self, text): - 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) - page.run_coro(lambda p: p.wait_for_timeout(0)) - @property def background_color(self): # Return a Color-like object with .r/.g/.b/.a so the stock assertions diff --git a/web-testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py index 6612537e85..54731f0d2f 100644 --- a/web-testbed/tests/widgets/conftest.py +++ b/web-testbed/tests/widgets/conftest.py @@ -2,7 +2,7 @@ # import toga from probe import get_probe -from tests.tests_backend.proxies.box_proxy import BoxProxy +from tests.tests_backend.proxies.object_proxies import BoxProxy @pytest.fixture diff --git a/web-testbed/tests/widgets/test_button.py b/web-testbed/tests/widgets/test_button.py index 6d9415505e..8c248896c5 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -1,8 +1,7 @@ from pytest import approx, fixture from tests.assertions import assert_background_color from tests.data import TEXTS -from tests.tests_backend.proxies.button_proxy import ButtonProxy -from tests.tests_backend.proxies.mock_proxy import MockProxy +from tests.tests_backend.proxies.object_proxies import ButtonProxy, MockProxy TRANSPARENT = "transparent" From c6bc67ad1a4c2445e12f283b0392eed6050e51b9 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Sun, 21 Sep 2025 19:47:13 +0800 Subject: [PATCH 22/37] Modify app.py so test_cmd exposed only if env TOGA_WEB_TESTING is true --- web-testbed/src/testbed/app.py | 221 ++++++++++++++++++--------------- 1 file changed, 122 insertions(+), 99 deletions(-) diff --git a/web-testbed/src/testbed/app.py b/web-testbed/src/testbed/app.py index 9a3262c5da..d6acba9e81 100644 --- a/web-testbed/src/testbed/app.py +++ b/web-testbed/src/testbed/app.py @@ -1,99 +1,122 @@ -import types -from unittest.mock import Mock - -import toga -from toga.style import Pack -from toga.style.pack import COLUMN - -try: - import js -except ModuleNotFoundError: - js = None -try: - from pyodide.ffi import create_proxy, to_js -except ModuleNotFoundError: - pyodide = None - - -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") - - if js is not None: - self.my_objs = {} - js.window.test_cmd = create_proxy(self.cmd_test) - - 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 cmd_test(self, code): - env = {"self": self, "toga": toga, "my_objs": self.my_objs, "Mock": Mock} - local = {} - try: - exec(code, env, local) - result = local.get("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} - - # 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 main(): - return HelloWorld() +import os +import types +from unittest.mock import Mock + +import toga +from toga.style import Pack +from toga.style.pack import COLUMN + +try: + import js +except ModuleNotFoundError: + js = None +try: + from pyodide.ffi import create_proxy, to_js +except ModuleNotFoundError: + pyodide = 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 "") + # enable if ?toga_web_testing=1 in url + if "toga_web_testing" in qs.lower(): + return True + except Exception: + pass + + return False + + +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") + + if _web_testing_enabled() and js is not None and create_proxy is not None: + self.my_objs = {} + js.window.test_cmd = create_proxy(self.cmd_test) + + 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 cmd_test(self, code): + env = {"self": self, "toga": toga, "my_objs": self.my_objs, "Mock": Mock} + local = {} + try: + exec(code, env, local) + result = local.get("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} + + # 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 main(): + return HelloWorld() From 1b46a5fd5068fd08ab1b49118bc2b4f720df2f11 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Sun, 21 Sep 2025 19:50:56 +0800 Subject: [PATCH 23/37] Added TOGA_WEB_TESTING to True when running Playwright --- .../tests/tests_backend/playwright_page.py | 144 +++++++++--------- 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index a2ec0cbf21..41b1f90a7b 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -1,71 +1,73 @@ -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): - try: - self._play = await async_playwright().start() - self._browser = await self._play.chromium.launch(headless=True) - self._context = await self._browser.new_context() - 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'" - ) - - self._alock = asyncio.Lock() - except Exception: - raise - finally: - 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)) +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): + try: + 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'" + ) + + self._alock = asyncio.Lock() + except Exception: + raise + finally: + 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)) From dfbfcad0be4df4f69f22503250df5872cb14eefd Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Sun, 28 Sep 2025 09:23:30 +0800 Subject: [PATCH 24/37] Added script, toga class shimming, web test harness for app.py, and other minor modifications. --- web-testbed/run_tests.py | 69 ++++++++++ web-testbed/src/testbed/app.py | 104 +-------------- web-testbed/src/testbed/web_test_harness.py | 123 ++++++++++++++++++ web-testbed/tests/conftest.py | 22 +--- .../tests/tests_backend/proxies/app_proxy.py | 6 - .../tests/tests_backend/proxies/base_proxy.py | 4 +- .../tests_backend/proxies/object_proxies.py | 18 +++ .../tests_backend/proxies/object_proxy.py | 2 +- .../tests/tests_backend/web_test_patch.py | 53 ++++++++ .../tests/tests_backend/widgets/button.py | 45 ++----- web-testbed/tests/widgets/conftest.py | 7 +- web-testbed/tests/widgets/probe.py | 9 +- web-testbed/tests/widgets/test_button.py | 9 +- 13 files changed, 296 insertions(+), 175 deletions(-) create mode 100644 web-testbed/run_tests.py create mode 100644 web-testbed/src/testbed/web_test_harness.py delete mode 100644 web-testbed/tests/tests_backend/proxies/app_proxy.py create mode 100644 web-testbed/tests/tests_backend/web_test_patch.py 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/app.py b/web-testbed/src/testbed/app.py index d6acba9e81..bda9750961 100644 --- a/web-testbed/src/testbed/app.py +++ b/web-testbed/src/testbed/app.py @@ -1,41 +1,8 @@ -import os -import types -from unittest.mock import Mock - import toga from toga.style import Pack from toga.style.pack import COLUMN -try: - import js -except ModuleNotFoundError: - js = None -try: - from pyodide.ffi import create_proxy, to_js -except ModuleNotFoundError: - pyodide = 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 "") - # enable if ?toga_web_testing=1 in url - if "toga_web_testing" in qs.lower(): - return True - except Exception: - pass - - return False +from .web_test_harness import WebTestHarness class HelloWorld(toga.App): @@ -43,80 +10,13 @@ def startup(self): main_box = toga.Box(style=Pack(direction=COLUMN)) self.label = toga.Label(id="myLabel", text="Test App - Toga Web Testing") - if _web_testing_enabled() and js is not None and create_proxy is not None: - self.my_objs = {} - js.window.test_cmd = create_proxy(self.cmd_test) + 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 cmd_test(self, code): - env = {"self": self, "toga": toga, "my_objs": self.my_objs, "Mock": Mock} - local = {} - try: - exec(code, env, local) - result = local.get("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} - - # 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 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..c804088988 --- /dev/null +++ b/web-testbed/src/testbed/web_test_harness.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import os +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 ModuleNotFoundError: + pyodide = None + 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._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) + + 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} + + # 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 diff --git a/web-testbed/tests/conftest.py b/web-testbed/tests/conftest.py index 1b6226d056..b7dfd43de8 100644 --- a/web-testbed/tests/conftest.py +++ b/web-testbed/tests/conftest.py @@ -1,30 +1,14 @@ # from pytest import fixture, register_assert_rewrite, skip -# import toga - import pytest -from .tests_backend.playwright_page import BackgroundPage -from .tests_backend.proxies.app_proxy import AppProxy -from .tests_backend.proxies.base_proxy import BaseProxy -from .tests_backend.widgets.base import SimpleProbe - - -@pytest.fixture(scope="session") -def page(): - p = BackgroundPage() - return p - +import toga -# Inject Playwright page object into -@pytest.fixture(scope="session", autouse=True) -def _wire_page(page): - BaseProxy.page_provider = staticmethod(lambda: page) - SimpleProbe.page_provider = staticmethod(lambda: page) +pytest_plugins = ["tests.tests_backend.web_test_patch"] @pytest.fixture(scope="session") def app(): - return AppProxy() + return toga.App.app() @pytest.fixture(scope="session") diff --git a/web-testbed/tests/tests_backend/proxies/app_proxy.py b/web-testbed/tests/tests_backend/proxies/app_proxy.py deleted file mode 100644 index 6f7f1cb5e8..0000000000 --- a/web-testbed/tests/tests_backend/proxies/app_proxy.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base_proxy import BaseProxy - - -class AppProxy(BaseProxy): - def __init__(self): - super().__init__("self") diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index 38c8b04081..492327d390 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -10,7 +10,7 @@ class BaseProxy: # Remote pure expression proxy # Attribute reads auto-realise primitives/containers, everything else stays proxied. - _storage_expr = "my_objs" + _storage_expr = "self.my_objs" page_provider = staticmethod(lambda: None) @@ -119,7 +119,7 @@ def _try_realise_value(self, expr_src: str): # Decode payload def _deserialise_payload(self, payload): - # Des-serialise strict typed envelopes: + # De-serialise strict typed envelopes: # - none/bool/int/float/str # - list/tuple/dict (recursive) # - object/callable -> proxy reference (my_objs[id]) diff --git a/web-testbed/tests/tests_backend/proxies/object_proxies.py b/web-testbed/tests/tests_backend/proxies/object_proxies.py index 07a92cada0..a73711b30a 100644 --- a/web-testbed/tests/tests_backend/proxies/object_proxies.py +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -1,13 +1,31 @@ +from .base_proxy import BaseProxy from .object_proxy import ObjectProxy +class AppProxy(BaseProxy): + def __init__(self): + 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" diff --git a/web-testbed/tests/tests_backend/proxies/object_proxy.py b/web-testbed/tests/tests_backend/proxies/object_proxy.py index b493d67c1f..512381593e 100644 --- a/web-testbed/tests/tests_backend/proxies/object_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/object_proxy.py @@ -8,7 +8,7 @@ class ObjectProxy(BaseProxy): def __init__(self, *args, **kwargs): key = self._create(self._ctor_expr, *args, **kwargs) - super().__init__(f"my_objs[{repr(key)}]") + super().__init__(f"self.my_objs[{repr(key)}]") @classmethod def _create(cls, ctor_expr: str, *args, **kwargs) -> str: 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..707bbb5d05 --- /dev/null +++ b/web-testbed/tests/tests_backend/web_test_patch.py @@ -0,0 +1,53 @@ +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() diff --git a/web-testbed/tests/tests_backend/widgets/button.py b/web-testbed/tests/tests_backend/widgets/button.py index c16dd434c5..ef539358fc 100644 --- a/web-testbed/tests/tests_backend/widgets/button.py +++ b/web-testbed/tests/tests_backend/widgets/button.py @@ -1,41 +1,22 @@ import re -from .base import SimpleProbe - - -class _ColorLike: - __slots__ = ("r", "g", "b", "a") - - def __init__(self, r, g, b, a=1.0): - self.r = int(r) - self.g = int(g) - self.b = int(b) - self.a = float(a) - - def __repr__(self): - return f"_ColorLike(r={self.r}, g={self.g}, b={self.b}, a={self.a})" +from travertino.colors import rgba +from .base import SimpleProbe -_CSS_RGBA_RE = re.compile( - r"rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)(?:\s*[/,]\s*([0-9.]+))?\s*\)", - re.IGNORECASE, -) +_rgb_re = re.compile(r"rgba?\(([^)]+)\)") -def _parse_css_rgba(s: str) -> "_ColorLike | None | str": - if s is None: +def css_to_travertino(css: str): + if not css or css == "transparent": return None - s = s.strip().lower() - # Treat literal 'transparent' as fully transparent black - if s == "transparent": - return _ColorLike(0, 0, 0, 0.0) - m = _CSS_RGBA_RE.match(s) + m = _rgb_re.search(css) if not m: - # Unknown format, return as-is - return s - r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3)) - a = float(m.group(4)) if m.group(4) is not None else 1.0 - return _ColorLike(r, g, b, a) + 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): @@ -68,9 +49,9 @@ def background_color(self): const el = document.querySelector(selector); if (!el) return null; const cs = getComputedStyle(el); - return cs.backgroundColor; // 'rgb(...)' or 'rgba(...)' + return cs.backgroundColor; }""", f"#{self.dom_id}", ) ) - return _parse_css_rgba(css) + return css_to_travertino(css) diff --git a/web-testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py index 54731f0d2f..e858c81fe4 100644 --- a/web-testbed/tests/widgets/conftest.py +++ b/web-testbed/tests/widgets/conftest.py @@ -1,8 +1,7 @@ import pytest - -# import toga from probe import get_probe -from tests.tests_backend.proxies.object_proxies import BoxProxy + +import toga @pytest.fixture @@ -13,7 +12,7 @@ async def widget(): @pytest.fixture async def probe(main_window, widget): old_content = main_window.content - box = BoxProxy(children=[widget]) + box = toga.Box(children=[widget]) main_window.content = box probe = get_probe(widget) yield probe diff --git a/web-testbed/tests/widgets/probe.py b/web-testbed/tests/widgets/probe.py index 384b88e665..9a00c3ecee 100644 --- a/web-testbed/tests/widgets/probe.py +++ b/web-testbed/tests/widgets/probe.py @@ -2,9 +2,6 @@ def get_probe(widget): - name = type(widget).__name__ # e.g. "ButtonProxy" - base = name.removesuffix("Proxy") # -> "Button" - module = import_module( - f"tests.tests_backend.widgets.{base.lower()}" - ) # -> tests/tests_backend/widgets/button.py - return getattr(module, f"{base}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/test_button.py b/web-testbed/tests/widgets/test_button.py index 8c248896c5..88cf0707b3 100644 --- a/web-testbed/tests/widgets/test_button.py +++ b/web-testbed/tests/widgets/test_button.py @@ -1,14 +1,17 @@ +from unittest.mock import Mock + from pytest import approx, fixture from tests.assertions import assert_background_color from tests.data import TEXTS -from tests.tests_backend.proxies.object_proxies import ButtonProxy, MockProxy + +import toga TRANSPARENT = "transparent" @fixture async def widget(): - return ButtonProxy("Hello") + return toga.Button("Hello") async def test_text(widget, probe): @@ -36,7 +39,7 @@ async def test_press(widget, probe): # 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 = MockProxy() + handler = Mock() widget.on_press = handler await probe.press() From fe87b1542b628e1411942289f650945609dcae3c Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Mon, 29 Sep 2025 14:40:08 +0800 Subject: [PATCH 25/37] Remove 'page_singleton.py' no-op. Integrate 'AttributeProxy' with '__getattr__' in 'base_proxy.py'. --- .../tests/tests_backend/playwright_page.py | 39 ++++++++----------- .../tests/tests_backend/proxies/base_proxy.py | 17 ++------ 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/web-testbed/tests/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index 41b1f90a7b..de908e33bf 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -29,28 +29,23 @@ def _run(self): self._loop.close() async def _bootstrap(self): - try: - 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'" - ) - - self._alock = asyncio.Lock() - except Exception: - raise - finally: - self._alock = asyncio.Lock() - self._ready.set() + 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'" + ) + + self._alock = asyncio.Lock() + self._ready.set() async def _eval(self, js, *args): async with self._alock: diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index 492327d390..e8c5a6001c 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -26,10 +26,11 @@ def _page(cls): return cls.page_provider() # Core methods + def __getattr__(self, name: str): - attr_expr = AttributeProxy(self, name) - ok, value = self._try_realise_value(attr_expr.js_ref) - return value if ok else attr_expr + expr = BaseProxy(f"getattr({self.js_ref}, {repr(name)})") # attribute handle + ok, value = self._try_realise_value(expr.js_ref) + return value if ok else expr def __setattr__(self, name: str, value): if name.startswith("_"): @@ -161,13 +162,3 @@ def _deserialise_payload(self, payload): return BaseProxy(f"{self._storage_expr}[{repr(obj_id)}]") raise ProxyProtocolError(f"Unknown payload type: {t!r}") - - -# In this file to avoid circular imports -class AttributeProxy(BaseProxy): - def __init__(self, owner: "BaseProxy", name: str): - self._js_ref = f"getattr({owner.js_ref}, {repr(name)})" - - @property - def js_ref(self) -> str: - return self._js_ref From 3da83f005d252ae1b077307da17b8befd32a1f32 Mon Sep 17 00:00:00 2001 From: Callum Horton <109891554+Stringer90@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:58:40 +0800 Subject: [PATCH 26/37] Fix recursive method calls in '_deserialise_payload()'. --- web-testbed/tests/tests_backend/proxies/base_proxy.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index e8c5a6001c..ac7f4a9d4c 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -118,7 +118,6 @@ def _try_realise_value(self, expr_src: str): return True, val return False, None - # Decode payload def _deserialise_payload(self, payload): # De-serialise strict typed envelopes: # - none/bool/int/float/str @@ -143,16 +142,16 @@ def _deserialise_payload(self, payload): # containers if t == "list": - return [self._decode_payload(item) for item in payload.get("items", [])] + return [self._deserialise_payload(item) for item in payload.get("items", [])] if t == "tuple": return tuple( - self._decode_payload(item) for item in payload.get("items", []) + 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._decode_payload(k_env) - v = self._decode_payload(v_env) + k = self._deserialise_payload(k_env) + v = self._deserialise_payload(v_env) out[k] = v return out @@ -162,3 +161,4 @@ def _deserialise_payload(self, payload): return BaseProxy(f"{self._storage_expr}[{repr(obj_id)}]") raise ProxyProtocolError(f"Unknown payload type: {t!r}") + From 10dc3099837bf84771edcc14a830d3b585fdeb0d Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:15:56 +0800 Subject: [PATCH 27/37] Added local policies and object caching --- .../tests/tests_backend/proxies/base_proxy.py | 76 ++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index ac7f4a9d4c..5ad0390895 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -6,16 +6,37 @@ class ProxyProtocolError(RuntimeError): 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. - _storage_expr = "self.my_objs" + _storage_expr = "self.my_objs" # change later 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: @@ -28,6 +49,11 @@ def _page(cls): # Core methods def __getattr__(self, name: str): + # local shadow + local = self._local_attrs + if name in local: + return local[name] + expr = BaseProxy(f"getattr({self.js_ref}, {repr(name)})") # attribute handle ok, value = self._try_realise_value(expr.js_ref) return value if ok else expr @@ -35,6 +61,17 @@ def __getattr__(self, name: str): def __setattr__(self, name: str, value): if name.startswith("_"): return super().__setattr__(name, value) + + # respect data descriptors on the class (e.g. @property setter) + cls_attr = getattr(type(self), name, None) + if hasattr(cls_attr, "__set__"): + return object.__setattr__(self, name, value) + + # local vs remote decision + if self._is_declared_local(name) or _contains_callable(value): + self._local_attrs[name] = value + return + code = f"setattr({self.js_ref}, {repr(name)}, {encode_value(value)})" self._eval_and_return(code) @@ -43,6 +80,12 @@ def __delattr__(self, name: str): if hasattr(self, name): return super().__delattr__(name) raise AttributeError(name) + + local = self._local_attrs + if name in local: + del local[name] + return + code = f"delattr({self.js_ref}, {repr(name)})" self._eval_and_return(code) @@ -118,6 +161,7 @@ def _try_realise_value(self, expr_src: str): return True, val return False, None + # Decode payload def _deserialise_payload(self, payload): # De-serialise strict typed envelopes: # - none/bool/int/float/str @@ -142,7 +186,9 @@ def _deserialise_payload(self, payload): # containers if t == "list": - return [self._deserialise_payload(item) for item in payload.get("items", [])] + 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", []) @@ -158,7 +204,31 @@ def _deserialise_payload(self, payload): # references if t in ("object", "callable"): obj_id = payload["id"] - return BaseProxy(f"{self._storage_expr}[{repr(obj_id)}]") + js_ref = f"{self._storage_expr}[{repr(obj_id)}]" + # return existing proxy if we already have one + existing = BaseProxy._instances.get(js_ref) + return existing if existing is not None else BaseProxy(js_ref) + + 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 + ) + + 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 From 2c058a2be095b0ae613ffee1691e3469495ba1a9 Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Thu, 2 Oct 2025 08:48:54 +0800 Subject: [PATCH 28/37] Change proxy line protocol from sending code strings to the web app to be executed to RPC. --- web-testbed/src/testbed/web_test_harness.py | 89 +++++++++++- .../tests/tests_backend/playwright_page.py | 3 + .../tests/tests_backend/proxies/base_proxy.py | 137 ++++++++++++------ .../tests/tests_backend/proxies/encoding.py | 35 ----- .../tests_backend/proxies/object_proxies.py | 3 +- 5 files changed, 188 insertions(+), 79 deletions(-) delete mode 100644 web-testbed/tests/tests_backend/proxies/encoding.py diff --git a/web-testbed/src/testbed/web_test_harness.py b/web-testbed/src/testbed/web_test_harness.py index c804088988..4e652231ff 100644 --- a/web-testbed/src/testbed/web_test_harness.py +++ b/web-testbed/src/testbed/web_test_harness.py @@ -13,8 +13,7 @@ try: from pyodide.ffi import create_proxy, to_js -except ModuleNotFoundError: - pyodide = None +except Exception: create_proxy = None to_js = None @@ -45,11 +44,14 @@ def __init__(self, app, *, expose_name: str = "test_cmd"): self.my_objs = {} self.app.my_objs = self.my_objs + 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: @@ -121,3 +123,86 @@ def _key_for(self, x): 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 == "ref": + return self.my_objs[str(env["id"])] + 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 + ) + + # 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/tests_backend/playwright_page.py b/web-testbed/tests/tests_backend/playwright_page.py index de908e33bf..0b5b57c05d 100644 --- a/web-testbed/tests/tests_backend/playwright_page.py +++ b/web-testbed/tests/tests_backend/playwright_page.py @@ -43,6 +43,9 @@ async def _bootstrap(self): 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() diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index 5ad0390895..f8bde124ec 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -1,6 +1,3 @@ -from .encoding import encode_value - - class ProxyProtocolError(RuntimeError): # Raised when the remote bridge returns an invalid or unexpected payload. pass @@ -22,7 +19,7 @@ class BaseProxy: # Remote pure expression proxy # Attribute reads auto-realise primitives/containers, everything else stays proxied. - _storage_expr = "self.my_objs" # change later + _storage_expr = "self.my_objs" page_provider = staticmethod(lambda: None) @@ -49,31 +46,28 @@ def _page(cls): # Core methods def __getattr__(self, name: str): - # local shadow local = self._local_attrs if name in local: return local[name] - - expr = BaseProxy(f"getattr({self.js_ref}, {repr(name)})") # attribute handle - ok, value = self._try_realise_value(expr.js_ref) - return value if ok else expr + 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 on the class (e.g. @property setter) + # 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) - # local vs remote decision + # keep local policy intact if self._is_declared_local(name) or _contains_callable(value): self._local_attrs[name] = value return - code = f"setattr({self.js_ref}, {repr(name)}, {encode_value(value)})" - self._eval_and_return(code) + # RPC setattr + env = self._serialise_for_rpc(value, self._storage_expr) + self._rpc("setattr", obj=self._ref(), name=name, value=env) def __delattr__(self, name: str): if name.startswith("_"): @@ -86,17 +80,14 @@ def __delattr__(self, name: str): del local[name] return - code = f"delattr({self.js_ref}, {repr(name)})" - self._eval_and_return(code) + self._rpc("delattr", obj=self._ref(), name=name) def __call__(self, *args, **kwargs): - parts = [] - if args: - parts += [encode_value(a) for a in args] - if kwargs: - parts += [f"{k}={encode_value(v)}" for k, v in kwargs.items()] - expr = f"{self.js_ref}({', '.join(parts)})" - return self._eval_and_return(expr) + 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): @@ -145,23 +136,6 @@ def _eval_and_return(self, expr_src: str): ) return self._deserialise_payload(payload) - def _try_realise_value(self, expr_src: str): - # Used by __getattr__, try to get a concrete value for primitives/containers. - # Returns (True, value) for str/int/float/bool/None, - # list/tuple/dict; else (False, None). - try: - val = self._eval_and_return(expr_src) - except Exception: - return False, None - if ( - isinstance(val, (str, int, float, bool)) - or val is None - or isinstance(val, (list, tuple, dict)) - ): - return True, val - return False, None - - # Decode payload def _deserialise_payload(self, payload): # De-serialise strict typed envelopes: # - none/bool/int/float/str @@ -205,9 +179,13 @@ def _deserialise_payload(self, payload): if t in ("object", "callable"): obj_id = payload["id"] js_ref = f"{self._storage_expr}[{repr(obj_id)}]" - # return existing proxy if we already have one existing = BaseProxy._instances.get(js_ref) - return existing if existing is not None else BaseProxy(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")) @@ -232,3 +210,80 @@ 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}[" + 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} + # 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) diff --git a/web-testbed/tests/tests_backend/proxies/encoding.py b/web-testbed/tests/tests_backend/proxies/encoding.py deleted file mode 100644 index 0e900d1b12..0000000000 --- a/web-testbed/tests/tests_backend/proxies/encoding.py +++ /dev/null @@ -1,35 +0,0 @@ -def encode_value(v) -> str: - # Encode a Python value or proxy-ish object. - # Supported: - # - proxies (via .js_ref) - # - primitives (str/int/float/bool/None) - # - list/tuple (recursive) - # - dict with primitive keys (recursive on values) - if hasattr(v, "js_ref"): # any proxy - return v.js_ref - - if isinstance(v, (str, int, float, bool)) or v is None: - return repr(v) - - if isinstance(v, list): - inner = ", ".join(encode_value(x) for x in v) - return f"[{inner}]" - - if isinstance(v, tuple): - inner = ", ".join(encode_value(x) for x in v) - if len(v) == 1: - inner += "," - return f"({inner})" - - if isinstance(v, dict): - items = ", ".join(f"{repr(k)}: {encode_value(val)}" for k, val in v.items()) - return f"{{{items}}}" - - # In toga, setting 'widget.text = object' implicitly uses str(object) - try: - return repr(str(v)) - except Exception as e: - raise TypeError( - f"Cannot encode {type(v).__name__}; pass a proxy (.js_ref), " - f"primitive, list/tuple, dict, or an object with a valid __str__()." - ) from e diff --git a/web-testbed/tests/tests_backend/proxies/object_proxies.py b/web-testbed/tests/tests_backend/proxies/object_proxies.py index a73711b30a..d6df689933 100644 --- a/web-testbed/tests/tests_backend/proxies/object_proxies.py +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -4,7 +4,8 @@ class AppProxy(BaseProxy): def __init__(self): - super().__init__("self") + super().__init__("self.my_objs['__app__']") + # super().__init__("self") AppProxy.__name__ = AppProxy.__qualname__ = "App" From 5f83d4b97fd70135b85147bda599dd6777cbc459 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Fri, 3 Oct 2025 07:53:47 +0800 Subject: [PATCH 29/37] Added callable source deserialization to enable runner to host validation. --- web-testbed/src/testbed/web_test_harness.py | 455 +++++++++++--------- 1 file changed, 247 insertions(+), 208 deletions(-) diff --git a/web-testbed/src/testbed/web_test_harness.py b/web-testbed/src/testbed/web_test_harness.py index 4e652231ff..f6c576c702 100644 --- a/web-testbed/src/testbed/web_test_harness.py +++ b/web-testbed/src/testbed/web_test_harness.py @@ -1,208 +1,247 @@ -from __future__ import annotations - -import os -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.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} - - # 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 == "ref": - return self.my_objs[str(env["id"])] - 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 - ) - - # 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}") +from __future__ import annotations + +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} + + # 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 + # 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}") From bd7522fdbec57b6ccd7c8d7554c419f3f8aca157 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Fri, 3 Oct 2025 07:56:25 +0800 Subject: [PATCH 30/37] Added four new test suites and updated conftest with new fixtures --- web-testbed/tests/widgets/conftest.py | 27 ++ web-testbed/tests/widgets/test_label.py | 48 +++ .../tests/widgets/test_passwordinput.py | 28 ++ web-testbed/tests/widgets/test_switch.py | 75 +++++ web-testbed/tests/widgets/test_textinput.py | 287 ++++++++++++++++++ 5 files changed, 465 insertions(+) create mode 100644 web-testbed/tests/widgets/test_passwordinput.py create mode 100644 web-testbed/tests/widgets/test_switch.py create mode 100644 web-testbed/tests/widgets/test_textinput.py diff --git a/web-testbed/tests/widgets/conftest.py b/web-testbed/tests/widgets/conftest.py index e858c81fe4..c8371d6ab8 100644 --- a/web-testbed/tests/widgets/conftest.py +++ b/web-testbed/tests/widgets/conftest.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pytest from probe import get_probe @@ -17,3 +19,28 @@ async def probe(main_window, widget): 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/test_label.py b/web-testbed/tests/widgets/test_label.py index e69de29bb2..60d4bd44fd 100644 --- a/web-testbed/tests/widgets/test_label.py +++ 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..866b8184b6 --- /dev/null +++ b/web-testbed/tests/widgets/test_passwordinput.py @@ -0,0 +1,28 @@ +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 \ No newline at end of file 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..9c08dc758e --- /dev/null +++ b/web-testbed/tests/widgets/test_textinput.py @@ -0,0 +1,287 @@ +from unittest.mock import Mock, call +import toga +import pytest + + +from toga.constants import CENTER +from toga.style import Pack +from toga.style.pack import RIGHT, SERIF + +from tests.data import TEXTS + + +@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() \ No newline at end of file From 6fc9f1f3303d36b9f11e41e21a7ef5972ef4c914 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Fri, 3 Oct 2025 08:00:07 +0800 Subject: [PATCH 31/37] Add widget to SHIMS and update apply() to allow call imports. --- .../tests/tests_backend/web_test_patch.py | 124 ++++++++++-------- 1 file changed, 71 insertions(+), 53 deletions(-) diff --git a/web-testbed/tests/tests_backend/web_test_patch.py b/web-testbed/tests/tests_backend/web_test_patch.py index 707bbb5d05..f5b9516974 100644 --- a/web-testbed/tests/tests_backend/web_test_patch.py +++ b/web-testbed/tests/tests_backend/web_test_patch.py @@ -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() From 5bd78a7cd0fbac447b6611fdc353856a5137b083 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Fri, 3 Oct 2025 08:01:57 +0800 Subject: [PATCH 32/37] Added new widgets and update base_proxy to serialize callables --- .../tests/tests_backend/proxies/base_proxy.py | 59 +++++++----- .../tests_backend/proxies/object_proxies.py | 92 ++++++++++++------- 2 files changed, 96 insertions(+), 55 deletions(-) diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index f8bde124ec..c8c0ff5547 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -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. @@ -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): @@ -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}[" @@ -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)} @@ -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) diff --git a/web-testbed/tests/tests_backend/proxies/object_proxies.py b/web-testbed/tests/tests_backend/proxies/object_proxies.py index d6df689933..07332093bc 100644 --- a/web-testbed/tests/tests_backend/proxies/object_proxies.py +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -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" From 22fbe52847b3517f430cc7064a11169052d3a9a5 Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Fri, 3 Oct 2025 08:02:43 +0800 Subject: [PATCH 33/37] Added new widget probes --- .../tests/tests_backend/widgets/label.py | 40 ++++++++ .../tests_backend/widgets/passwordinput.py | 4 + .../tests/tests_backend/widgets/switch.py | 17 ++++ .../tests/tests_backend/widgets/textinput.py | 99 +++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 web-testbed/tests/tests_backend/widgets/passwordinput.py create mode 100644 web-testbed/tests/tests_backend/widgets/switch.py create mode 100644 web-testbed/tests/tests_backend/widgets/textinput.py diff --git a/web-testbed/tests/tests_backend/widgets/label.py b/web-testbed/tests/tests_backend/widgets/label.py index e69de29bb2..fc74d94ba5 100644 --- a/web-testbed/tests/tests_backend/widgets/label.py +++ 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..0774ade02d --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/passwordinput.py @@ -0,0 +1,4 @@ +from .textinput import TextInputProbe + +class PasswordInputProbe(TextInputProbe): + pass \ No newline at end of file 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..f9b4fd1986 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/switch.py @@ -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()) \ No newline at end of file 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..3fd71cbf30 --- /dev/null +++ b/web-testbed/tests/tests_backend/widgets/textinput.py @@ -0,0 +1,99 @@ +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() + + From 354f8aec98f2cb6d720e68fade2b3c44d134f1b4 Mon Sep 17 00:00:00 2001 From: Veronica Taniputra Date: Fri, 3 Oct 2025 09:37:54 +0800 Subject: [PATCH 34/37] Fixed pre-commit issues --- .../tests_backend/widgets/passwordinput.py | 3 +- .../tests/tests_backend/widgets/switch.py | 7 +++-- .../tests/tests_backend/widgets/textinput.py | 29 ++++++++++++------- .../tests/widgets/test_passwordinput.py | 3 +- web-testbed/tests/widgets/test_textinput.py | 12 ++++---- 5 files changed, 32 insertions(+), 22 deletions(-) diff --git a/web-testbed/tests/tests_backend/widgets/passwordinput.py b/web-testbed/tests/tests_backend/widgets/passwordinput.py index 0774ade02d..2c7937cae9 100644 --- a/web-testbed/tests/tests_backend/widgets/passwordinput.py +++ b/web-testbed/tests/tests_backend/widgets/passwordinput.py @@ -1,4 +1,5 @@ from .textinput import TextInputProbe + class PasswordInputProbe(TextInputProbe): - pass \ No newline at end of file + pass diff --git a/web-testbed/tests/tests_backend/widgets/switch.py b/web-testbed/tests/tests_backend/widgets/switch.py index f9b4fd1986..7c6f487a85 100644 --- a/web-testbed/tests/tests_backend/widgets/switch.py +++ b/web-testbed/tests/tests_backend/widgets/switch.py @@ -1,17 +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()) \ No newline at end of file + 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 index 3fd71cbf30..89099dad78 100644 --- a/web-testbed/tests/tests_backend/widgets/textinput.py +++ b/web-testbed/tests/tests_backend/widgets/textinput.py @@ -1,5 +1,6 @@ from .base import SimpleProbe + class TextInputProbe(SimpleProbe): def __init__(self, widget): super().__init__(widget) @@ -11,14 +12,17 @@ def _read_remote_value(self) -> str: @property def value(self): - page = self._page() + 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 @@ -35,20 +39,26 @@ async def steps(): 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 + 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") @@ -58,6 +68,7 @@ async def steps(): await target.press("Backspace") else: await target.type(ch) + return steps() page.run_coro(_run) @@ -66,11 +77,10 @@ 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( @@ -78,7 +88,8 @@ def set_cursor_at_end(self): """(sel) => { const root = document.querySelector(sel); if (!root) return; - const el = root.matches('input,textarea') ? root : root.querySelector('input,textarea'); + const el = root.matches('input,textarea') ? + root : root.querySelector('input,textarea'); if (!el) return; el.focus(); const len = (el.value ?? '').length; @@ -95,5 +106,3 @@ async def redraw(self, _msg: str = ""): 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/widgets/test_passwordinput.py b/web-testbed/tests/widgets/test_passwordinput.py index 866b8184b6..23ed89dd99 100644 --- a/web-testbed/tests/widgets/test_passwordinput.py +++ b/web-testbed/tests/widgets/test_passwordinput.py @@ -3,7 +3,6 @@ import toga - @pytest.fixture async def widget(): return toga.PasswordInput(value="sekrit") @@ -25,4 +24,4 @@ async def test_value_hidden(widget, probe): widget.value = "something" await probe.redraw("Value changed from empty to non-empty") - assert probe.value_hidden \ No newline at end of file + assert probe.value_hidden diff --git a/web-testbed/tests/widgets/test_textinput.py b/web-testbed/tests/widgets/test_textinput.py index 9c08dc758e..54637a04e7 100644 --- a/web-testbed/tests/widgets/test_textinput.py +++ b/web-testbed/tests/widgets/test_textinput.py @@ -1,14 +1,12 @@ from unittest.mock import Mock, call -import toga -import pytest +import pytest +from tests.data import TEXTS +import toga from toga.constants import CENTER -from toga.style import Pack from toga.style.pack import RIGHT, SERIF -from tests.data import TEXTS - @pytest.fixture async def widget(): @@ -69,6 +67,7 @@ async def test_on_change_programmatic(widget, probe, on_change, focused, placeho 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. @@ -266,6 +265,7 @@ async def test_no_event_on_initialization(widget, probe, on_change): 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 @@ -284,4 +284,4 @@ async def test_no_event_on_style_change(widget, probe, on_change): widget.style.color = "#0000FF" await probe.redraw("Text color has been changed") on_change.assert_not_called() - on_change.reset_mock() \ No newline at end of file + on_change.reset_mock() From 20d97ce368e63e537da5d06c50c79c49c8b23f14 Mon Sep 17 00:00:00 2001 From: Veronica Taniputra Date: Tue, 7 Oct 2025 00:54:10 +0800 Subject: [PATCH 35/37] Added date/time envelopes and tests --- web-testbed/src/testbed/web_test_harness.py | 16 ++ .../tests/tests_backend/proxies/base_proxy.py | 35 +++- .../tests_backend/proxies/object_proxies.py | 14 ++ .../tests/tests_backend/web_test_patch.py | 4 + .../tests/tests_backend/widgets/dateinput.py | 45 +++++ .../tests/tests_backend/widgets/timeinput.py | 63 +++++++ web-testbed/tests/widgets/test_dateinput.py | 178 ++++++++++++++++++ web-testbed/tests/widgets/test_timeinput.py | 112 +++++++++++ 8 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 web-testbed/tests/tests_backend/widgets/dateinput.py create mode 100644 web-testbed/tests/tests_backend/widgets/timeinput.py create mode 100644 web-testbed/tests/widgets/test_dateinput.py create mode 100644 web-testbed/tests/widgets/test_timeinput.py diff --git a/web-testbed/src/testbed/web_test_harness.py b/web-testbed/src/testbed/web_test_harness.py index f6c576c702..78ed2accf1 100644 --- a/web-testbed/src/testbed/web_test_harness.py +++ b/web-testbed/src/testbed/web_test_harness.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime as _dt import os import textwrap import types @@ -110,6 +111,10 @@ def _serialise_payload(self, x): 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) @@ -155,6 +160,17 @@ def _deserialise(self, 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: diff --git a/web-testbed/tests/tests_backend/proxies/base_proxy.py b/web-testbed/tests/tests_backend/proxies/base_proxy.py index c8c0ff5547..84efd7b579 100644 --- a/web-testbed/tests/tests_backend/proxies/base_proxy.py +++ b/web-testbed/tests/tests_backend/proxies/base_proxy.py @@ -1,3 +1,4 @@ +import datetime as _dt import inspect @@ -37,9 +38,10 @@ def _page(cls): # Core methods def __getattr__(self, name: str): - local = self._local_attrs - if name in local: - return local[name] + 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): @@ -64,6 +66,7 @@ def __setattr__(self, name: str, value): 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("_"): @@ -77,6 +80,7 @@ def __delattr__(self, name: str): 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] @@ -171,6 +175,23 @@ def _deserialise_payload(self, payload): 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"] @@ -259,6 +280,14 @@ def _serialise_for_rpc(self, v, storage_expr="self.my_objs"): 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" diff --git a/web-testbed/tests/tests_backend/proxies/object_proxies.py b/web-testbed/tests/tests_backend/proxies/object_proxies.py index 07332093bc..6a67840813 100644 --- a/web-testbed/tests/tests_backend/proxies/object_proxies.py +++ b/web-testbed/tests/tests_backend/proxies/object_proxies.py @@ -58,3 +58,17 @@ class PasswordInputProxy(ObjectProxy): 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/web_test_patch.py b/web-testbed/tests/tests_backend/web_test_patch.py index f5b9516974..e692b772ec 100644 --- a/web-testbed/tests/tests_backend/web_test_patch.py +++ b/web-testbed/tests/tests_backend/web_test_patch.py @@ -10,11 +10,13 @@ AppProxy, BoxProxy, ButtonProxy, + DateInputProxy, LabelProxy, MockProxy, PasswordInputProxy, SwitchProxy, TextInputProxy, + TimeInputProxy, ) from .widgets.base import SimpleProbe @@ -43,6 +45,8 @@ def _wire_page(page): ("toga", "Switch", SwitchProxy), ("toga", "TextInput", TextInputProxy), ("toga", "PasswordInput", PasswordInputProxy), + ("toga", "TimeInput", TimeInputProxy), + ("toga", "DateInput", DateInputProxy), ("unittest.mock", "Mock", MockProxy), ] 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/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/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_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 From e435dbb3226f3e47741dfa1cbe9732e9caf8b27c Mon Sep 17 00:00:00 2001 From: Callum Horton Date: Tue, 7 Oct 2025 10:55:24 +0800 Subject: [PATCH 36/37] Added back 'encoding.py' to fix errors. Quieter script output. --- web-testbed/run_tests.py | 5 ++- .../tests/tests_backend/proxies/encoding.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 web-testbed/tests/tests_backend/proxies/encoding.py diff --git a/web-testbed/run_tests.py b/web-testbed/run_tests.py index 55dd648839..7cfa2b4543 100644 --- a/web-testbed/run_tests.py +++ b/web-testbed/run_tests.py @@ -6,7 +6,10 @@ from shutil import which SERVER_CMD = ["briefcase", "run", "web", "--no-browser"] -TEST_CMD = ["pytest", "tests"] +# TEST_CMD = ["pytest", "tests"] + +# Quieter output ('-rN' to remove 'short test summary info') +TEST_CMD = ["pytest", "--tb=no", "--disable-warnings", "-rN", "tests"] STARTUP_WAIT_SECS = float(os.getenv("SERVER_STARTUP_SECS", "5.0")) IS_WINDOWS = os.name == "nt" diff --git a/web-testbed/tests/tests_backend/proxies/encoding.py b/web-testbed/tests/tests_backend/proxies/encoding.py new file mode 100644 index 0000000000..cae20b9b36 --- /dev/null +++ b/web-testbed/tests/tests_backend/proxies/encoding.py @@ -0,0 +1,35 @@ +def encode_value(v) -> str: + # Encode a Python value or proxy-ish object. + # Supported: + # - proxies (via .js_ref) + # - primitives (str/int/float/bool/None) + # - list/tuple (recursive) + # - dict with primitive keys (recursive on values) + if hasattr(v, "js_ref"): # any proxy + return v.js_ref + + if isinstance(v, (str, int, float, bool)) or v is None: + return repr(v) + + if isinstance(v, list): + inner = ", ".join(encode_value(x) for x in v) + return f"[{inner}]" + + if isinstance(v, tuple): + inner = ", ".join(encode_value(x) for x in v) + if len(v) == 1: + inner += "," + return f"({inner})" + + if isinstance(v, dict): + items = ", ".join(f"{repr(k)}: {encode_value(val)}" for k, val in v.items()) + return f"{{{items}}}" + + try: + return repr(str(v)) + + except Exception as e: + raise TypeError( + f"Cannot encode {type(v).__name__}; pass a proxy (.js_ref), " + f"primitive, list/tuple, dict, or an object with a valid __str__()." + ) from e From e6a2367eb48226063b7ef4090495e6301a7c7e0d Mon Sep 17 00:00:00 2001 From: vt37 <110211722+vt37@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:37:28 +0800 Subject: [PATCH 37/37] Fixed the name placeholder issue --- web-testbed/tests/tests_backend/web_test_patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-testbed/tests/tests_backend/web_test_patch.py b/web-testbed/tests/tests_backend/web_test_patch.py index e692b772ec..69c4d48ffb 100644 --- a/web-testbed/tests/tests_backend/web_test_patch.py +++ b/web-testbed/tests/tests_backend/web_test_patch.py @@ -56,7 +56,7 @@ def apply(): try: mod = importlib.import_module(mod_name) except Exception: - if mod_name.startswith(("toga", "yourpackageprefix")): + if mod_name.startswith("toga"): mod = types.ModuleType(mod_name) sys.modules[mod_name] = mod else: