Pyjoyment 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. Pyjo/App/ASGI.py +735 -0
  2. Pyjo/App/CGI.py +773 -0
  3. Pyjo/App/FastCGI.py +631 -0
  4. Pyjo/App/Lambda.py +987 -0
  5. Pyjo/App/WSGI.py +427 -0
  6. Pyjo/App/__init__.py +409 -0
  7. Pyjo/Asset/File.py +377 -0
  8. Pyjo/Asset/Memory.py +235 -0
  9. Pyjo/Asset/__init__.py +200 -0
  10. Pyjo/Base.py +215 -0
  11. Pyjo/Cache.py +119 -0
  12. Pyjo/Collection.py +570 -0
  13. Pyjo/Content/MultiPart.py +325 -0
  14. Pyjo/Content/Single.py +193 -0
  15. Pyjo/Content/__init__.py +806 -0
  16. Pyjo/Cookie/Request.py +90 -0
  17. Pyjo/Cookie/Response.py +294 -0
  18. Pyjo/Cookie/__init__.py +143 -0
  19. Pyjo/DOM/CSS.py +1019 -0
  20. Pyjo/DOM/HTML.py +550 -0
  21. Pyjo/DOM/__init__.py +1329 -0
  22. Pyjo/Date.py +294 -0
  23. Pyjo/EventEmitter.py +522 -0
  24. Pyjo/Exception.py +494 -0
  25. Pyjo/File.py +959 -0
  26. Pyjo/GetOpt.py +605 -0
  27. Pyjo/Headers.py +760 -0
  28. Pyjo/HelloWorld.py +77 -0
  29. Pyjo/Home.py +109 -0
  30. Pyjo/IOLoop/ChildProcess.py +591 -0
  31. Pyjo/IOLoop/Client.py +736 -0
  32. Pyjo/IOLoop/Datagram.py +982 -0
  33. Pyjo/IOLoop/Messages.py +664 -0
  34. Pyjo/IOLoop/Server.py +458 -0
  35. Pyjo/IOLoop/Stdio.py +446 -0
  36. Pyjo/IOLoop/Stream.py +644 -0
  37. Pyjo/IOLoop/Subprocess.py +419 -0
  38. Pyjo/IOLoop/TLS.py +360 -0
  39. Pyjo/IOLoop/__init__.py +1036 -0
  40. Pyjo/IOLoop/certs/README.md +41 -0
  41. Pyjo/IOLoop/certs/ca.crt +20 -0
  42. Pyjo/IOLoop/certs/server.crt +21 -0
  43. Pyjo/IOLoop/certs/server.key +28 -0
  44. Pyjo/JSON/Pointer.py +135 -0
  45. Pyjo/JSON/RPC.py +772 -0
  46. Pyjo/JSON/Schema.py +2506 -0
  47. Pyjo/JSON/__init__.py +573 -0
  48. Pyjo/Loader.py +342 -0
  49. Pyjo/Log.py +723 -0
  50. Pyjo/Message/Request.py +725 -0
  51. Pyjo/Message/Response.py +476 -0
  52. Pyjo/Message/__init__.py +830 -0
  53. Pyjo/Parameters.py +472 -0
  54. Pyjo/Path.py +419 -0
  55. Pyjo/Promise.py +894 -0
  56. Pyjo/Prove.py +1305 -0
  57. Pyjo/Reactor/Asyncio.py +854 -0
  58. Pyjo/Reactor/Poll.py +439 -0
  59. Pyjo/Reactor/__init__.py +525 -0
  60. Pyjo/SSE.py +119 -0
  61. Pyjo/Server/ASGI.py +486 -0
  62. Pyjo/Server/CGI.py +259 -0
  63. Pyjo/Server/Daemon.py +1007 -0
  64. Pyjo/Server/FastCGI.py +517 -0
  65. Pyjo/Server/Hot/__init__.py +687 -0
  66. Pyjo/Server/Hot/__main__.py +5 -0
  67. Pyjo/Server/Lambda.py +641 -0
  68. Pyjo/Server/Prefork.py +917 -0
  69. Pyjo/Server/Prethread.py +394 -0
  70. Pyjo/Server/Reload/Backend/Poll.py +114 -0
  71. Pyjo/Server/Reload/Backend/__init__.py +126 -0
  72. Pyjo/Server/Reload/__init__.py +482 -0
  73. Pyjo/Server/Reload/__main__.py +5 -0
  74. Pyjo/Server/Stdio.py +228 -0
  75. Pyjo/Server/WSGI.py +349 -0
  76. Pyjo/Server/__init__.py +396 -0
  77. Pyjo/String/Bytes.py +532 -0
  78. Pyjo/String/HTML.py +210 -0
  79. Pyjo/String/Mixin.py +333 -0
  80. Pyjo/String/Template.py +470 -0
  81. Pyjo/String/Unicode.py +553 -0
  82. Pyjo/String/__init__.py +0 -0
  83. Pyjo/TOML.py +232 -0
  84. Pyjo/Template.py +1290 -0
  85. Pyjo/Test/Pyjo.py +1454 -0
  86. Pyjo/Test/__init__.py +1233 -0
  87. Pyjo/Test/pytest_plugin.py +305 -0
  88. Pyjo/Transaction/HTTP.py +374 -0
  89. Pyjo/Transaction/WebSocket.py +709 -0
  90. Pyjo/Transaction/__init__.py +429 -0
  91. Pyjo/URL/Template.py +459 -0
  92. Pyjo/URL/__init__.py +651 -0
  93. Pyjo/Upload.py +139 -0
  94. Pyjo/UserAgent/CookieJar.py +391 -0
  95. Pyjo/UserAgent/Proxy.py +138 -0
  96. Pyjo/UserAgent/Server.py +239 -0
  97. Pyjo/UserAgent/Transactor.py +766 -0
  98. Pyjo/UserAgent/__init__.py +1243 -0
  99. Pyjo/Util.py +3567 -0
  100. Pyjo/WebSocket.py +332 -0
  101. Pyjo/YAML.py +1167 -0
  102. Pyjo/__init__.py +441 -0
  103. Pyjo/__main__.py +35 -0
  104. Pyjo/py.typed +0 -0
  105. Pyjoyment/Command/Author/__init__.py +20 -0
  106. Pyjoyment/Command/Author/generate/__init__.py +106 -0
  107. Pyjoyment/Command/Author/generate/app.py +299 -0
  108. Pyjoyment/Command/Author/generate/dockerfile.py +281 -0
  109. Pyjoyment/Command/Author/generate/kubernetes.py +514 -0
  110. Pyjoyment/Command/Author/generate/lambda_zip.py +372 -0
  111. Pyjoyment/Command/Author/generate/lite_app.py +113 -0
  112. Pyjoyment/Command/Author/generate/plugin.py +267 -0
  113. Pyjoyment/Command/Author/generate/pyproject.py +123 -0
  114. Pyjoyment/Command/Author/inflate.py +101 -0
  115. Pyjoyment/Command/__init__.py +332 -0
  116. Pyjoyment/Command/asgi.py +92 -0
  117. Pyjoyment/Command/cgi.py +90 -0
  118. Pyjoyment/Command/daemon.py +160 -0
  119. Pyjoyment/Command/eval.py +128 -0
  120. Pyjoyment/Command/fastcgi.py +99 -0
  121. Pyjoyment/Command/get.py +305 -0
  122. Pyjoyment/Command/lambda.py +117 -0
  123. Pyjoyment/Command/prefork.py +181 -0
  124. Pyjoyment/Command/prethread.py +126 -0
  125. Pyjoyment/Command/routes.py +133 -0
  126. Pyjoyment/Command/stdio.py +92 -0
  127. Pyjoyment/Command/version.py +141 -0
  128. Pyjoyment/Command/wsgi.py +93 -0
  129. Pyjoyment/Commands.py +546 -0
  130. Pyjoyment/Controller.py +1307 -0
  131. Pyjoyment/HelloWorld.py +56 -0
  132. Pyjoyment/Lite.py +494 -0
  133. Pyjoyment/Plugin/AllowedHosts.py +205 -0
  134. Pyjoyment/Plugin/Config.py +248 -0
  135. Pyjoyment/Plugin/DefaultHelpers.py +1202 -0
  136. Pyjoyment/Plugin/EPLRenderer.py +134 -0
  137. Pyjoyment/Plugin/EPRenderer.py +212 -0
  138. Pyjoyment/Plugin/HeaderCondition.py +98 -0
  139. Pyjoyment/Plugin/Health.py +325 -0
  140. Pyjoyment/Plugin/JSONConfig.py +172 -0
  141. Pyjoyment/Plugin/Mount.py +223 -0
  142. Pyjoyment/Plugin/PyRenderer.py +209 -0
  143. Pyjoyment/Plugin/TOMLConfig.py +141 -0
  144. Pyjoyment/Plugin/TagHelpers.py +959 -0
  145. Pyjoyment/Plugin/YAMLConfig.py +178 -0
  146. Pyjoyment/Plugin/__init__.py +80 -0
  147. Pyjoyment/Plugins.py +215 -0
  148. Pyjoyment/Renderer.py +673 -0
  149. Pyjoyment/Routes/Match.py +251 -0
  150. Pyjoyment/Routes/Pattern.py +443 -0
  151. Pyjoyment/Routes/Route.py +827 -0
  152. Pyjoyment/Routes/__init__.py +419 -0
  153. Pyjoyment/Sessions.py +255 -0
  154. Pyjoyment/Static.py +421 -0
  155. Pyjoyment/Types.py +238 -0
  156. Pyjoyment/Validator/Validation.py +395 -0
  157. Pyjoyment/Validator/__init__.py +304 -0
  158. Pyjoyment/__init__.py +1083 -0
  159. Pyjoyment/__main__.py +5 -0
  160. Pyjoyment/py.typed +0 -0
  161. Pyjoyment/resources/public/favicon.ico +0 -0
  162. Pyjoyment/resources/public/favicon.svg +345 -0
  163. Pyjoyment/resources/public/pyjo/bootstrap/LICENSE +21 -0
  164. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.bundle.min.js.map +1 -0
  165. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.css +6 -0
  166. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.js +7 -0
  167. Pyjoyment/resources/public/pyjo/bootstrap/bootstrap.min.css.map +1 -0
  168. Pyjoyment/resources/public/pyjo/failunicat.png +0 -0
  169. Pyjoyment/resources/public/pyjo/failunicat.svg +50 -0
  170. Pyjoyment/resources/public/pyjo/highlight.js/LICENSE +29 -0
  171. Pyjoyment/resources/public/pyjo/highlight.js/highlight-mojo-dark.css +56 -0
  172. Pyjoyment/resources/public/pyjo/highlight.js/highlight.min.js +1262 -0
  173. Pyjoyment/resources/public/pyjo/highlight.js/pyjoyment.min.js +1 -0
  174. Pyjoyment/resources/public/pyjo/logo-black-4x.png +0 -0
  175. Pyjoyment/resources/public/pyjo/logo-black-4x.svg +346 -0
  176. Pyjoyment/resources/public/pyjo/logo-white-2x.png +0 -0
  177. Pyjoyment/resources/public/pyjo/logo-white-2x.svg +346 -0
  178. Pyjoyment/resources/public/pyjo/logo-white-4x.png +0 -0
  179. Pyjoyment/resources/public/pyjo/logo-white-4x.svg +346 -0
  180. Pyjoyment/resources/public/pyjo/logo-white.png +0 -0
  181. Pyjoyment/resources/public/pyjo/logo-white.svg +346 -0
  182. Pyjoyment/resources/public/pyjo/logo.png +0 -0
  183. Pyjoyment/resources/public/pyjo/logo.svg +346 -0
  184. Pyjoyment/resources/public/pyjo/notfound.png +0 -0
  185. Pyjoyment/resources/public/pyjo/notfound.svg +3 -0
  186. Pyjoyment/resources/public/pyjo/nounicat.png +0 -0
  187. Pyjoyment/resources/public/pyjo/nounicat.svg +5 -0
  188. Pyjoyment/resources/public/pyjo/pinstripe-dark.png +0 -0
  189. Pyjoyment/resources/public/pyjo/pinstripe-dark.svg +4 -0
  190. Pyjoyment/resources/public/pyjo/pinstripe-light.png +0 -0
  191. Pyjoyment/resources/public/pyjo/pinstripe-light.svg +4 -0
  192. Pyjoyment/resources/public/pyjo/pyjo.css +204 -0
  193. Pyjoyment/resources/templates/pyjo/debug.html.ep +346 -0
  194. Pyjoyment/resources/templates/pyjo/exception.html.ep +32 -0
  195. Pyjoyment/resources/templates/pyjo/not_found.html.ep +45 -0
  196. pyjoyment-0.1.0.dist-info/METADATA +376 -0
  197. pyjoyment-0.1.0.dist-info/RECORD +200 -0
  198. pyjoyment-0.1.0.dist-info/WHEEL +4 -0
  199. pyjoyment-0.1.0.dist-info/entry_points.txt +8 -0
  200. pyjoyment-0.1.0.dist-info/licenses/LICENSE +201 -0
Pyjo/Test/Pyjo.py ADDED
@@ -0,0 +1,1454 @@
1
+ """
2
+ Pyjo.Test.Pyjo - Testing Pyjo
3
+ =============================
4
+ ::
5
+
6
+ from Pyjo.Test import *
7
+
8
+ import Pyjo.Test.Pyjo
9
+
10
+ t = Pyjo.Test.Pyjo.new('MyApp')
11
+
12
+ # HTML/XML
13
+ t.get_ok('/welcome').status_is(200).text_is('div#message', 'Hello!')
14
+
15
+ # JSON
16
+ (
17
+ t.post_ok('/search.json', form={'q': 'Python'})
18
+ .status_is(200)
19
+ .header_is('Server', 'Pyjoyment (Python)')
20
+ .header_isnt('X-Bender', 'Bite my shiny metal ass!')
21
+ .json_is('/results/4/title', 'Python rocks!')
22
+ .json_like('/results/7/title', re.compile(r'Python'))
23
+ )
24
+
25
+ # WebSocket
26
+ t.websocket_ok('/echo').send_ok('hello').message_ok().message_is('echo: hello').finish_ok()
27
+
28
+ # Server-Sent Events (SSE)
29
+ (
30
+ t.get_sse_ok('/events')
31
+ .status_is(200)
32
+ .sse_ok()
33
+ .sse_type_is('message')
34
+ .sse_text_is('hello pyjo')
35
+ .sse_finish_ok()
36
+ )
37
+
38
+ done_testing()
39
+
40
+ :mod:`Pyjo.Test.Pyjo` is a test user agent based on :mod:`Pyjo.UserAgent`, it
41
+ is usually used together with :mod:`Pyjo.Test` to test :mod:`Pyjoyment`
42
+ applications. Just run your tests with :mod:`Pyjo.Prove`. ::
43
+
44
+ $ python -m Pyjo.Prove -l -v
45
+ $ python -m Pyjo.Prove -l -v t/foo.py
46
+
47
+ If it is not already defined, the ``PYJO_LOG_LEVEL`` environment variable
48
+ will be set to ``trace`` or ``fatal``, depending on the value of the
49
+ ``PYJO_HARNESS_IS_VERBOSE`` environment variable, which
50
+ ``python -m Pyjo.Prove -v`` sets, or without it of ``HARNESS_IS_VERBOSE``,
51
+ which Perl's ``prove -v`` sets. And to make it easier to test HTTPS/WSS web
52
+ services :attr:`Pyjo.UserAgent.Pyjo_UserAgent.insecure` will be activated by
53
+ default for :attr:`~Pyjo_Test_Pyjo.ua`.
54
+
55
+ The end of a ``with`` block closes the connections and the servers of its
56
+ :attr:`~Pyjo_Test_Pyjo.ua`.
57
+
58
+ See also
59
+ --------
60
+
61
+ :mod:`Pyjoyment`, https://mojolicious.org.
62
+
63
+ Ported from `Test::Mojo <https://github.com/mojolicious/mojo/blob/v9.49/lib/Test/Mojo.pm>`_ of Mojolicious 9.49.
64
+
65
+ Classes
66
+ -------
67
+ """
68
+
69
+ import Pyjo.Base
70
+ import Pyjo.File
71
+ import Pyjo.IOLoop
72
+ import Pyjo.JSON.Pointer
73
+ import Pyjo.Server
74
+ import Pyjo.Test
75
+ import Pyjo.UserAgent
76
+
77
+ from Pyjo.JSON import j
78
+ from Pyjo.Util import decode
79
+
80
+ import os
81
+ import re
82
+
83
+ from collections.abc import Callable
84
+ from types import TracebackType
85
+ from typing import TYPE_CHECKING, Any, Self, cast
86
+
87
+
88
+ if TYPE_CHECKING:
89
+ import Pyjo.Transaction.HTTP
90
+
91
+
92
+ # Silent or loud tests
93
+ # Unlike Mojolicious: PYJO_HARNESS_IS_VERBOSE of Pyjo.Prove, and HARNESS_IS_VERBOSE of prove without it
94
+ if not os.environ.get('PYJO_LOG_LEVEL'):
95
+ verbose = os.environ.get('PYJO_HARNESS_IS_VERBOSE', os.environ.get('HARNESS_IS_VERBOSE'))
96
+ os.environ['PYJO_LOG_LEVEL'] = 'trace' if verbose else 'fatal'
97
+
98
+ # Not in Mojolicious: pytest leaves the frames of this module out of the traceback of a failed test, as those of
99
+ # Pyjo.Test
100
+ __tracebackhide__ = Pyjo.Test.__tracebackhide__
101
+
102
+
103
+ class Pyjo_Test_Pyjo(Pyjo.Base.object):
104
+ """
105
+ :mod:`Pyjo.Test.Pyjo` inherits all methods from :mod:`Pyjo.Base` and
106
+ implements the following new ones.
107
+ """
108
+
109
+ def __init__(self, app: Any = None, config: dict[str, Any] | None = None, /) -> None:
110
+ """::
111
+
112
+ t = Pyjo.Test.Pyjo.new()
113
+ t = Pyjo.Test.Pyjo.new('MyApp')
114
+ t = Pyjo.Test.Pyjo.new('MyApp', {'foo': 'bar'})
115
+ t = Pyjo.Test.Pyjo.new(Pyjo.File.new('/path/to/myapp.py'))
116
+ t = Pyjo.Test.Pyjo.new(Pyjo.File.new('/path/to/myapp.py'), {'foo': 'bar'})
117
+ t = Pyjo.Test.Pyjo.new(MyApp.new())
118
+ t = Pyjo.Test.Pyjo.new(MyApp.new(), {'foo': 'bar'})
119
+
120
+ Construct a new :mod:`Pyjo.Test.Pyjo` object. In addition to a class
121
+ name or :mod:`Pyjo.File` object pointing to the application script,
122
+ you can pass along a dict with configuration values that will be used
123
+ to override the application configuration. The special configuration
124
+ value ``config_override`` will be set in
125
+ :meth:`Pyjoyment.Pyjoyment_App.config` as well, which is used to
126
+ disable configuration plugins for tests. ::
127
+
128
+ # Load application script relative to the "t" directory
129
+ from Pyjo.File import curfile
130
+
131
+ t = Pyjo.Test.Pyjo.new(curfile().dirname().sibling('myapp.py'))
132
+ """
133
+ super().__init__()
134
+
135
+ self.handler: Callable[..., Any] = _handler
136
+ """::
137
+
138
+ cb = t.handler
139
+ t.handler = lambda name, *args: ...
140
+
141
+ A callback to connect :mod:`Pyjo.Test.Pyjo` with :mod:`Pyjo.Test`,
142
+ which gets the name of a function of ``Test::More``, as ``is``, and
143
+ its arguments. ::
144
+
145
+ def handler(name, *args):
146
+ return {'is': is_ok, 'like': like_ok, 'ok': ok}[name](*args)
147
+
148
+ t.handler = handler
149
+ """
150
+
151
+ self.message: list[Any] | None = None
152
+ """::
153
+
154
+ msg = t.message
155
+ t.message = ['text', b'bytes']
156
+
157
+ Current WebSocket message represented as a list containing the frame
158
+ type and payload. ::
159
+
160
+ # More specific tests
161
+ from Pyjo.JSON import decode_json
162
+
163
+ hash = decode_json(t.message[1])
164
+ is_ok(type(hash), dict, 'right reference')
165
+ is_ok(hash['foo'], 'bar', 'right value')
166
+
167
+ # Test custom message
168
+ t.message = ['binary', b'bytes']
169
+ t.json_message_has('/foo/bar').json_message_hasnt('/bar').json_message_is('/foo/baz', {'yada': [1, 2, 3]})
170
+ """
171
+
172
+ self.sse: dict[str, Any] | None = None
173
+ """::
174
+
175
+ event = t.sse
176
+ t.sse = {'type': 'message', 'text': 'working!'}
177
+
178
+ Current Server-Sent Event (SSE) represented as a dict. Note that this
179
+ attribute is **EXPERIMENTAL** and may change without warning!
180
+ """
181
+
182
+ self.success: bool | None = None
183
+ """::
184
+
185
+ boolean = t.success
186
+ t.success = boolean
187
+
188
+ True if the last test was successful. ::
189
+
190
+ # Build custom tests
191
+ def location_is(t, value, desc=''):
192
+ desc = desc or f'Location: {value}'
193
+ t.success = bool(is_ok(t.tx.res.headers.location, value, desc))
194
+ return t
195
+
196
+ t.get_ok('/').status_is(302)
197
+ location_is(t, 'https://mojolicious.org').otherwise(lambda t: diag('Must have been Joel!'))
198
+ """
199
+
200
+ self.tx: Pyjo.Transaction.HTTP.object | None = None
201
+ """::
202
+
203
+ tx = t.tx
204
+ t.tx = Pyjo.Transaction.HTTP.new()
205
+
206
+ Current transaction, usually a :mod:`Pyjo.Transaction.HTTP` or
207
+ :mod:`Pyjo.Transaction.WebSocket` object. ::
208
+
209
+ # More specific tests
210
+ is_ok(t.tx.res.json()['foo'], 'bar', 'right value')
211
+ ok(t.tx.res.content.is_multipart, 'multipart content')
212
+ is_ok(t.tx.previous.res.code, 302, 'right status')
213
+ """
214
+
215
+ self._ua: Pyjo.UserAgent.object | None = None
216
+ self._finished: list[Any] | None = None
217
+ self._messages: list[list[Any]] = []
218
+ self._sse_events: list[dict[str, Any]] = []
219
+ self._sse_finished: bool | None = None
220
+
221
+ if app is None:
222
+ return
223
+
224
+ cfg: dict[str, Any] = {'config': {'config_override': 1, **config}} if config is not None else {}
225
+ if isinstance(app, (str, type)):
226
+ self.app = Pyjo.Server.new().build_app(app, **cfg)
227
+ elif isinstance(app, (Pyjo.File.object, os.PathLike)):
228
+ self.app = Pyjo.Server.new().load_app(app, **cfg) # pyright: ignore[reportUnknownArgumentType]
229
+ else:
230
+ if cfg:
231
+ app.config(cfg['config'])
232
+ self.app = app
233
+
234
+ # Not in Mojolicious: a context manager, which closes the connections and the servers of its user agent where the
235
+ # block ends, as PyPy frees the object only when its garbage collector runs
236
+ def __enter__(self) -> Self:
237
+ return self
238
+
239
+ def __exit__(
240
+ self, type: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None
241
+ ) -> None:
242
+ self.close()
243
+
244
+ @property
245
+ def ua(self) -> Pyjo.UserAgent.object:
246
+ """::
247
+
248
+ ua = t.ua
249
+ t.ua = Pyjo.UserAgent.new()
250
+
251
+ User agent used for testing, defaults to a :mod:`Pyjo.UserAgent`
252
+ object. ::
253
+
254
+ # Allow redirects
255
+ t.ua.max_redirects = 10
256
+ t.get_ok('/redirect').status_is(200).content_like(re.compile(r'redirected'))
257
+
258
+ # Switch protocol from HTTP to HTTPS
259
+ t.ua.server.url('https')
260
+ t.get_ok('/secure').status_is(200).content_like(re.compile(r'secure'))
261
+
262
+ # Use absolute URL for request with Basic authentication
263
+ url = t.ua.server.url().set(userinfo='sri:secr3t').set(path='/secrets.json')
264
+ t.post_ok(url, json={'limit': 10}).status_is(200).json_is('/1/content', 'Pyjo rocks!')
265
+
266
+ # Customize all transactions (including followed redirects)
267
+ t.ua.on('start', lambda ua, tx: tx.req.headers.set(accept_language='en-US'))
268
+ t.get_ok('/hello').status_is(200).content_like(re.compile(r'Howdy'))
269
+ """
270
+ if self._ua is None:
271
+ self._ua = Pyjo.UserAgent.new(insecure=True, ioloop=Pyjo.IOLoop.singleton)
272
+ return self._ua
273
+
274
+ @ua.setter
275
+ def ua(self, value: Pyjo.UserAgent.object) -> None:
276
+ self._ua = value
277
+
278
+ @property
279
+ def app(self) -> Any:
280
+ """::
281
+
282
+ app = t.app
283
+ t.app = Pyjoyment.new()
284
+
285
+ Access application with :attr:`Pyjo.UserAgent.Server.Pyjo_UserAgent_Server.app`. ::
286
+
287
+ # Change log level
288
+ t.app.log.level = 'fatal'
289
+
290
+ # Test application directly
291
+ is_ok(t.app.defaults('foo'), 'bar', 'right value')
292
+ ok(t.app.routes.find('echo').is_websocket, 'WebSocket route')
293
+ c = t.app.build_controller()
294
+ ok(c.render(template='foo'), 'rendering was successful')
295
+ is_ok(c.res.code, 200, 'right status')
296
+ is_ok(c.res.body, b'Foo!', 'right content')
297
+
298
+ # Change application behavior
299
+ @t.app.hook('before_dispatch')
300
+ def before_dispatch(c):
301
+ if c.req.url.path.contains('/user'):
302
+ c.render(text='This request did not reach the router.')
303
+
304
+ t.get_ok('/user').status_is(200).content_like(re.compile(r'not reach the router'))
305
+
306
+ # Extract additional information
307
+ stash = {}
308
+ t.app.hook('after_dispatch', lambda c: stash.update(c.stash()))
309
+ t.get_ok('/hello').status_is(200)
310
+ is_ok(stash['foo'], 'bar', 'right value')
311
+ """
312
+ return self.ua.server.app
313
+
314
+ @app.setter
315
+ def app(self, value: Any) -> None:
316
+ self.ua.server.app = value
317
+
318
+ def attr_is(self, selector: str, attr: str, value: Any, desc: str | None = None) -> Self:
319
+ """::
320
+
321
+ t = t.attr_is('img.cat', 'alt', 'Grumpy cat')
322
+ t = t.attr_is('img.cat', 'alt', 'Grumpy cat', 'right alt text')
323
+
324
+ Checks text content of attribute with :meth:`Pyjo.DOM.Pyjo_DOM.attr` at
325
+ the CSS selectors first matching HTML/XML element for exact match with
326
+ :meth:`Pyjo.DOM.Pyjo_DOM.at`.
327
+ """
328
+ desc = _desc(desc, f'exact match for attribute "{attr}" at selector "{selector}"')
329
+ return self.test('is', self._attr(selector, attr), value, desc)
330
+
331
+ def attr_isnt(self, selector: str, attr: str, value: Any, desc: str | None = None) -> Self:
332
+ """::
333
+
334
+ t = t.attr_isnt('img.cat', 'alt', 'Calm cat')
335
+ t = t.attr_isnt('img.cat', 'alt', 'Calm cat', 'different alt text')
336
+
337
+ Opposite of :meth:`attr_is`.
338
+ """
339
+ desc = _desc(desc, f'no match for attribute "{attr}" at selector "{selector}"')
340
+ return self.test('isnt', self._attr(selector, attr), value, desc)
341
+
342
+ def attr_like(self, selector: str, attr: str, regex: Any, desc: str | None = None) -> Self:
343
+ """::
344
+
345
+ t = t.attr_like('img.cat', 'alt', re.compile(r'Grumpy'))
346
+ t = t.attr_like('img.cat', 'alt', re.compile(r'Grumpy'), 'right alt text')
347
+
348
+ Checks text content of attribute with :meth:`Pyjo.DOM.Pyjo_DOM.attr` at
349
+ the CSS selectors first matching HTML/XML element for similar match
350
+ with :meth:`Pyjo.DOM.Pyjo_DOM.at`.
351
+ """
352
+ desc = _desc(desc, f'similar match for attribute "{attr}" at selector "{selector}"')
353
+ return self.test('like', self._attr(selector, attr), regex, desc)
354
+
355
+ def attr_unlike(self, selector: str, attr: str, regex: Any, desc: str | None = None) -> Self:
356
+ """::
357
+
358
+ t = t.attr_unlike('img.cat', 'alt', re.compile(r'Calm'))
359
+ t = t.attr_unlike('img.cat', 'alt', re.compile(r'Calm'), 'different alt text')
360
+
361
+ Opposite of :meth:`attr_like`.
362
+ """
363
+ desc = _desc(desc, f'no similar match for attribute "{attr}" at selector "{selector}"')
364
+ return self.test('unlike', self._attr(selector, attr), regex, desc)
365
+
366
+ # Not in Mojolicious: the release of the user agent, which Perl does in DESTROY, also at the end of a with block
367
+ def close(self) -> None:
368
+ """::
369
+
370
+ t.close()
371
+
372
+ Close the connections and the servers of :attr:`ua`, as the end of a
373
+ ``with`` block does.
374
+ """
375
+ if self._ua is not None:
376
+ self._ua.close()
377
+
378
+ def content_is(self, value: Any, desc: str | None = None) -> Self:
379
+ """::
380
+
381
+ t = t.content_is('working!')
382
+ t = t.content_is('working!', 'right content')
383
+
384
+ Check response content for exact match after retrieving it from
385
+ :attr:`Pyjo.Message.Pyjo_Message.text`.
386
+ """
387
+ return self.test('is', self._tx.res.text, value, _desc(desc, 'exact match for content'))
388
+
389
+ def content_isnt(self, value: Any, desc: str | None = None) -> Self:
390
+ """::
391
+
392
+ t = t.content_isnt('working!')
393
+ t = t.content_isnt('working!', 'different content')
394
+
395
+ Opposite of :meth:`content_is`.
396
+ """
397
+ return self.test('isnt', self._tx.res.text, value, _desc(desc, 'no match for content'))
398
+
399
+ def content_like(self, regex: Any, desc: str | None = None) -> Self:
400
+ """::
401
+
402
+ t = t.content_like(re.compile(r'working!'))
403
+ t = t.content_like(re.compile(r'working!'), 'right content')
404
+
405
+ Check response content for similar match after retrieving it from
406
+ :attr:`Pyjo.Message.Pyjo_Message.text`.
407
+ """
408
+ return self.test('like', self._tx.res.text, regex, _desc(desc, 'content is similar'))
409
+
410
+ def content_type_is(self, type: str, desc: str | None = None) -> Self:
411
+ """::
412
+
413
+ t = t.content_type_is('text/html')
414
+ t = t.content_type_is('text/html', 'right content type')
415
+
416
+ Check response ``Content-Type`` header for exact match.
417
+ """
418
+ return self.test('is', self._tx.res.headers.content_type, type, _desc(desc, f'Content-Type: {type}'))
419
+
420
+ def content_type_isnt(self, type: str, desc: str | None = None) -> Self:
421
+ """::
422
+
423
+ t = t.content_type_isnt('text/html')
424
+ t = t.content_type_isnt('text/html', 'different content type')
425
+
426
+ Opposite of :meth:`content_type_is`.
427
+ """
428
+ return self.test('isnt', self._tx.res.headers.content_type, type, _desc(desc, f'not Content-Type: {type}'))
429
+
430
+ def content_type_like(self, regex: Any, desc: str | None = None) -> Self:
431
+ """::
432
+
433
+ t = t.content_type_like(re.compile(r'text'))
434
+ t = t.content_type_like(re.compile(r'text'), 'right content type')
435
+
436
+ Check response ``Content-Type`` header for similar match.
437
+ """
438
+ return self.test('like', self._tx.res.headers.content_type, regex, _desc(desc, 'Content-Type is similar'))
439
+
440
+ def content_type_unlike(self, regex: Any, desc: str | None = None) -> Self:
441
+ """::
442
+
443
+ t = t.content_type_unlike(re.compile(r'text'))
444
+ t = t.content_type_unlike(re.compile(r'text'), 'different content type')
445
+
446
+ Opposite of :meth:`content_type_like`.
447
+ """
448
+ desc = _desc(desc, 'Content-Type is not similar')
449
+ return self.test('unlike', self._tx.res.headers.content_type, regex, desc)
450
+
451
+ def content_unlike(self, regex: Any, desc: str | None = None) -> Self:
452
+ """::
453
+
454
+ t = t.content_unlike(re.compile(r'working!'))
455
+ t = t.content_unlike(re.compile(r'working!'), 'different content')
456
+
457
+ Opposite of :meth:`content_like`.
458
+ """
459
+ return self.test('unlike', self._tx.res.text, regex, _desc(desc, 'content is not similar'))
460
+
461
+ def delete_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
462
+ """::
463
+
464
+ t = t.delete_ok('http://example.com/foo')
465
+ t = t.delete_ok('/foo')
466
+ t = t.delete_ok('/foo', {'Accept': '*/*'}, 'Content!')
467
+ t = t.delete_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
468
+ t = t.delete_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
469
+
470
+ Perform a ``DELETE`` request and check for transport errors, takes the
471
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.delete`, except
472
+ for the callback.
473
+ """
474
+ return self._build_ok('DELETE', url, *args, **kwargs)
475
+
476
+ def element_count_is(self, selector: str, count: int, desc: str | None = None) -> Self:
477
+ """::
478
+
479
+ t = t.element_count_is('div.foo[x=y]', 5)
480
+ t = t.element_count_is('html body div', 30, 'thirty elements')
481
+
482
+ Checks the number of HTML/XML elements matched by the CSS selector
483
+ with :meth:`Pyjo.DOM.Pyjo_DOM.find`.
484
+ """
485
+ size = len(self._dom().find(selector))
486
+ return self.test('is', size, count, _desc(desc, f'element count for selector "{selector}"'))
487
+
488
+ def element_exists(self, selector: str, desc: str | None = None) -> Self:
489
+ """::
490
+
491
+ t = t.element_exists('div.foo[x=y]')
492
+ t = t.element_exists('html head title', 'has a title')
493
+
494
+ Checks for existence of the CSS selectors first matching HTML/XML
495
+ element with :meth:`Pyjo.DOM.Pyjo_DOM.at`. ::
496
+
497
+ # Check attribute values
498
+ (
499
+ t.get_ok('/login')
500
+ .element_exists('label[for=email]')
501
+ .element_exists('input[name=email][type=text][value*="example.com"]')
502
+ .element_exists('label[for=pass]')
503
+ .element_exists('input[name=pass][type=password]')
504
+ .element_exists('input[type=submit][value]')
505
+ )
506
+ """
507
+ desc = _desc(desc, f'element for selector "{selector}" exists')
508
+ return self.test('ok', self._dom().at(selector), desc)
509
+
510
+ def element_exists_not(self, selector: str, desc: str | None = None) -> Self:
511
+ """::
512
+
513
+ t = t.element_exists_not('div.foo[x=y]')
514
+ t = t.element_exists_not('html head title', 'has no title')
515
+
516
+ Opposite of :meth:`element_exists`.
517
+ """
518
+ return self.test('ok', not self._dom().at(selector), _desc(desc, f'no element for selector "{selector}"'))
519
+
520
+ def finish_ok(self, *args: Any) -> Self:
521
+ """::
522
+
523
+ t = t.finish_ok()
524
+ t = t.finish_ok(1000)
525
+ t = t.finish_ok(1003, 'Cannot accept data!')
526
+
527
+ Close WebSocket connection gracefully.
528
+ """
529
+ if self._tx.is_websocket:
530
+ self._tx.finish(*args)
531
+ while self._finished is None:
532
+ Pyjo.IOLoop.one_tick()
533
+ return self.test('ok', 1, 'closed WebSocket')
534
+
535
+ def finished_ok(self, code: int) -> Self:
536
+ """::
537
+
538
+ t = t.finished_ok(1000)
539
+
540
+ Wait for WebSocket connection to be closed gracefully and check status.
541
+ """
542
+ while self._finished is None:
543
+ Pyjo.IOLoop.one_tick()
544
+ status = self._finished[0] if len(self._finished) else None
545
+ if not (ok := status == code):
546
+ Pyjo.Test.diag(f'WebSocket closed with status {status}')
547
+ return self.test('ok', ok, f'WebSocket closed with status {code}')
548
+
549
+ def get_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
550
+ """::
551
+
552
+ t = t.get_ok('http://example.com/foo')
553
+ t = t.get_ok('/foo')
554
+ t = t.get_ok('/foo', {'Accept': '*/*'}, 'Content!')
555
+ t = t.get_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
556
+ t = t.get_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
557
+
558
+ Perform a ``GET`` request and check for transport errors, takes the
559
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.get`, except
560
+ for the callback. ::
561
+
562
+ # Run tests against remote host
563
+ t.get_ok('https://github.com').status_is(200)
564
+
565
+ # Use relative URL for request with Basic authentication
566
+ t.get_ok('//sri:secr3t@/secrets.json').status_is(200).json_is('/1/content', 'Pyjo rocks!')
567
+
568
+ # Run additional tests on the transaction
569
+ t.get_ok('/foo').status_is(200)
570
+ is_ok(t.tx.res.dom().at('input').val, 'whatever', 'right value')
571
+ """
572
+ return self._build_ok('GET', url, *args, **kwargs)
573
+
574
+ def get_sse_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
575
+ """::
576
+
577
+ t = t.get_sse_ok('http://example.com/events')
578
+ t = t.get_sse_ok('/events')
579
+ t = t.get_sse_ok('/events', {'Accept': 'text/event-stream'}, 'Content!')
580
+ t = t.get_sse_ok('/events', {'Accept': 'text/event-stream'}, form={'a': 'b'})
581
+ t = t.get_sse_ok('/events', {'Accept': 'text/event-stream'}, json={'a': 'b'})
582
+
583
+ Perform a ``GET`` request to establish a Server-Sent Events (SSE)
584
+ connection, takes the same arguments as
585
+ :meth:`Pyjo.UserAgent.Pyjo_UserAgent.get`, except for the callback.
586
+ Note that this method is **EXPERIMENTAL** and may change without
587
+ warning!
588
+ """
589
+ return self._build_sse_ok('GET', url, *args, **kwargs)
590
+
591
+ def head_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
592
+ """::
593
+
594
+ t = t.head_ok('http://example.com/foo')
595
+ t = t.head_ok('/foo')
596
+ t = t.head_ok('/foo', {'Accept': '*/*'}, 'Content!')
597
+ t = t.head_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
598
+ t = t.head_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
599
+
600
+ Perform a ``HEAD`` request and check for transport errors, takes the
601
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.head`, except
602
+ for the callback.
603
+ """
604
+ return self._build_ok('HEAD', url, *args, **kwargs)
605
+
606
+ def header_exists(self, name: str, desc: str | None = None) -> Self:
607
+ """::
608
+
609
+ t = t.header_exists('ETag')
610
+ t = t.header_exists('ETag', 'header exists')
611
+
612
+ Check if response header exists.
613
+ """
614
+ exists = bool(len(self._tx.res.headers.every_header(name)))
615
+ return self.test('ok', exists, _desc(desc, f'header "{name}" exists'))
616
+
617
+ def header_exists_not(self, name: str, desc: str | None = None) -> Self:
618
+ """::
619
+
620
+ t = t.header_exists_not('ETag')
621
+ t = t.header_exists_not('ETag', 'header is missing')
622
+
623
+ Opposite of :meth:`header_exists`.
624
+ """
625
+ missing = not len(self._tx.res.headers.every_header(name))
626
+ return self.test('ok', missing, _desc(desc, f'no "{name}" header'))
627
+
628
+ def header_is(self, name: str, value: Any, desc: str | None = None) -> Self:
629
+ """::
630
+
631
+ t = t.header_is('ETag', '"abc321"')
632
+ t = t.header_is('ETag', '"abc321"', 'right header')
633
+
634
+ Check response header for exact match.
635
+ """
636
+ desc = _desc(desc, f'{name}: {"" if value is None else value}')
637
+ return self.test('is', self._tx.res.headers.header(name), value, desc)
638
+
639
+ def header_isnt(self, name: str, value: Any, desc: str | None = None) -> Self:
640
+ """::
641
+
642
+ t = t.header_isnt('Etag', '"abc321"')
643
+ t = t.header_isnt('ETag', '"abc321"', 'different header')
644
+
645
+ Opposite of :meth:`header_is`.
646
+ """
647
+ desc = _desc(desc, f'not {name}: {"" if value is None else value}')
648
+ return self.test('isnt', self._tx.res.headers.header(name), value, desc)
649
+
650
+ def header_like(self, name: str, regex: Any, desc: str | None = None) -> Self:
651
+ """::
652
+
653
+ t = t.header_like('ETag', re.compile(r'abc'))
654
+ t = t.header_like('ETag', re.compile(r'abc'), 'right header')
655
+
656
+ Check response header for similar match.
657
+ """
658
+ return self.test('like', self._tx.res.headers.header(name), regex, _desc(desc, f'{name} is similar'))
659
+
660
+ def header_unlike(self, name: str, regex: Any, desc: str | None = None) -> Self:
661
+ """::
662
+
663
+ t = t.header_unlike('ETag', re.compile(r'abc'))
664
+ t = t.header_unlike('ETag', re.compile(r'abc'), 'different header')
665
+
666
+ Opposite of :meth:`header_like`.
667
+ """
668
+ return self.test('unlike', self._tx.res.headers.header(name), regex, _desc(desc, f'{name} is not similar'))
669
+
670
+ def json_has(self, p: str, desc: str | None = None) -> Self:
671
+ """::
672
+
673
+ t = t.json_has('/foo')
674
+ t = t.json_has('/minibar', 'has a minibar')
675
+
676
+ Check if JSON response contains a value that can be identified using
677
+ the given JSON Pointer with :mod:`Pyjo.JSON.Pointer`.
678
+ """
679
+ desc = _desc(desc, f'has value for JSON Pointer "{p}"')
680
+ return self.test('ok', bool(Pyjo.JSON.Pointer.new(self._tx.res.json()).contains(p)), desc)
681
+
682
+ def json_hasnt(self, p: str, desc: str | None = None) -> Self:
683
+ """::
684
+
685
+ t = t.json_hasnt('/foo')
686
+ t = t.json_hasnt('/minibar', 'no minibar')
687
+
688
+ Opposite of :meth:`json_has`.
689
+ """
690
+ desc = _desc(desc, f'has no value for JSON Pointer "{p}"')
691
+ return self.test('ok', not Pyjo.JSON.Pointer.new(self._tx.res.json()).contains(p), desc)
692
+
693
+ def json_is(self, *args: Any) -> Self:
694
+ """::
695
+
696
+ t = t.json_is({'foo': [1, 2, 3]})
697
+ t = t.json_is('/foo', [1, 2, 3])
698
+ t = t.json_is('/foo/1', 2, 'right value')
699
+
700
+ Check the value extracted from JSON response using the given JSON
701
+ Pointer with :mod:`Pyjo.JSON.Pointer`, which defaults to the root value
702
+ if it is omitted. ::
703
+
704
+ # Use an empty JSON Pointer to test the whole JSON response with a test description
705
+ t.json_is('', {'foo': [1, 2, 3]}, 'right object')
706
+ """
707
+ p, data, desc = _json_args(args)
708
+ return self.test('is_deeply', self._tx.res.json(p), data, _desc(desc, f'exact match for JSON Pointer "{p}"'))
709
+
710
+ def json_like(self, p: str, regex: Any, desc: str | None = None) -> Self:
711
+ """::
712
+
713
+ t = t.json_like('/foo/1', re.compile(r'^\\d+$'))
714
+ t = t.json_like('/foo/1', re.compile(r'^\\d+$'), 'right value')
715
+
716
+ Check the value extracted from JSON response using the given JSON
717
+ Pointer with :mod:`Pyjo.JSON.Pointer` for similar match.
718
+ """
719
+ desc = _desc(desc, f'similar match for JSON Pointer "{p}"')
720
+ return self.test('like', self._tx.res.json(p), regex, desc)
721
+
722
+ def json_message_has(self, p: str, desc: str | None = None) -> Self:
723
+ """::
724
+
725
+ t = t.json_message_has('/foo')
726
+ t = t.json_message_has('/minibar', 'has a minibar')
727
+
728
+ Check if JSON WebSocket message contains a value that can be
729
+ identified using the given JSON Pointer with :mod:`Pyjo.JSON.Pointer`.
730
+ """
731
+ return self.test('ok', self._json('contains', p), _desc(desc, f'has value for JSON Pointer "{p}"'))
732
+
733
+ def json_message_hasnt(self, p: str, desc: str | None = None) -> Self:
734
+ """::
735
+
736
+ t = t.json_message_hasnt('/foo')
737
+ t = t.json_message_hasnt('/minibar', 'no minibar')
738
+
739
+ Opposite of :meth:`json_message_has`.
740
+ """
741
+ return self.test('ok', not self._json('contains', p), _desc(desc, f'has no value for JSON Pointer "{p}"'))
742
+
743
+ def json_message_is(self, *args: Any) -> Self:
744
+ """::
745
+
746
+ t = t.json_message_is({'foo': [1, 2, 3]})
747
+ t = t.json_message_is('/foo', [1, 2, 3])
748
+ t = t.json_message_is('/foo/1', 2, 'right value')
749
+
750
+ Check the value extracted from JSON WebSocket message using the given
751
+ JSON Pointer with :mod:`Pyjo.JSON.Pointer`, which defaults to the root
752
+ value if it is omitted.
753
+ """
754
+ p, data, desc = _json_args(args)
755
+ return self.test('is_deeply', self._json('get', p), data, _desc(desc, f'exact match for JSON Pointer "{p}"'))
756
+
757
+ def json_message_like(self, p: str, regex: Any, desc: str | None = None) -> Self:
758
+ """::
759
+
760
+ t = t.json_message_like('/foo/1', re.compile(r'^\\d+$'))
761
+ t = t.json_message_like('/foo/1', re.compile(r'^\\d+$'), 'right value')
762
+
763
+ Check the value extracted from JSON WebSocket message using the given
764
+ JSON Pointer with :mod:`Pyjo.JSON.Pointer` for similar match.
765
+ """
766
+ desc = _desc(desc, f'similar match for JSON Pointer "{p}"')
767
+ return self.test('like', self._json('get', p), regex, desc)
768
+
769
+ def json_message_unlike(self, p: str, regex: Any, desc: str | None = None) -> Self:
770
+ """::
771
+
772
+ t = t.json_message_unlike('/foo/1', re.compile(r'^\\d+$'))
773
+ t = t.json_message_unlike('/foo/1', re.compile(r'^\\d+$'), 'different value')
774
+
775
+ Opposite of :meth:`json_message_like`.
776
+ """
777
+ desc = _desc(desc, f'no similar match for JSON Pointer "{p}"')
778
+ return self.test('unlike', self._json('get', p), regex, desc)
779
+
780
+ def json_unlike(self, p: str, regex: Any, desc: str | None = None) -> Self:
781
+ """::
782
+
783
+ t = t.json_unlike('/foo/1', re.compile(r'^\\d+$'))
784
+ t = t.json_unlike('/foo/1', re.compile(r'^\\d+$'), 'different value')
785
+
786
+ Opposite of :meth:`json_like`.
787
+ """
788
+ desc = _desc(desc, f'no similar match for JSON Pointer "{p}"')
789
+ return self.test('unlike', self._tx.res.json(p), regex, desc)
790
+
791
+ def message_is(self, value: Any, desc: str | None = None) -> Self:
792
+ """::
793
+
794
+ t = t.message_is({'binary': b'bytes'})
795
+ t = t.message_is({'text': b'bytes'})
796
+ t = t.message_is('working!')
797
+ t = t.message_is('working!', 'right message')
798
+
799
+ Check WebSocket message for exact match.
800
+ """
801
+ return self._message('is', value, _desc(desc, 'exact match for message'))
802
+
803
+ def message_isnt(self, value: Any, desc: str | None = None) -> Self:
804
+ """::
805
+
806
+ t = t.message_isnt({'binary': b'bytes'})
807
+ t = t.message_isnt({'text': b'bytes'})
808
+ t = t.message_isnt('working!')
809
+ t = t.message_isnt('working!', 'different message')
810
+
811
+ Opposite of :meth:`message_is`.
812
+ """
813
+ return self._message('isnt', value, _desc(desc, 'no match for message'))
814
+
815
+ def message_like(self, regex: Any, desc: str | None = None) -> Self:
816
+ """::
817
+
818
+ t = t.message_like({'binary': re.compile(rb'bytes')})
819
+ t = t.message_like({'text': re.compile(rb'bytes')})
820
+ t = t.message_like(re.compile(r'working!'))
821
+ t = t.message_like(re.compile(r'working!'), 'right message')
822
+
823
+ Check WebSocket message for similar match.
824
+ """
825
+ return self._message('like', regex, _desc(desc, 'message is similar'))
826
+
827
+ def message_ok(self, desc: str | None = None) -> Self:
828
+ """::
829
+
830
+ t = t.message_ok()
831
+ t = t.message_ok('got a message')
832
+
833
+ Wait for next WebSocket message to arrive. ::
834
+
835
+ # Wait for message and perform multiple tests on it
836
+ (
837
+ t.websocket_ok('/time')
838
+ .message_ok()
839
+ .message_like(re.compile(r'\\d+'))
840
+ .message_unlike(re.compile(r'\\w+'))
841
+ .finish_ok()
842
+ )
843
+ """
844
+ return self.test('ok', bool(self._wait()), _desc(desc, 'message received'))
845
+
846
+ def message_unlike(self, regex: Any, desc: str | None = None) -> Self:
847
+ """::
848
+
849
+ t = t.message_unlike({'binary': re.compile(rb'bytes')})
850
+ t = t.message_unlike({'text': re.compile(rb'bytes')})
851
+ t = t.message_unlike(re.compile(r'working!'))
852
+ t = t.message_unlike(re.compile(r'working!'), 'different message')
853
+
854
+ Opposite of :meth:`message_like`.
855
+ """
856
+ return self._message('unlike', regex, _desc(desc, 'message is not similar'))
857
+
858
+ def options_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
859
+ """::
860
+
861
+ t = t.options_ok('http://example.com/foo')
862
+ t = t.options_ok('/foo')
863
+ t = t.options_ok('/foo', {'Accept': '*/*'}, 'Content!')
864
+ t = t.options_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
865
+ t = t.options_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
866
+
867
+ Perform a ``OPTIONS`` request and check for transport errors, takes the
868
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.options`, except
869
+ for the callback.
870
+ """
871
+ return self._build_ok('OPTIONS', url, *args, **kwargs)
872
+
873
+ def otherwise(self, cb: Callable[[Self], Any]) -> Self:
874
+ """::
875
+
876
+ t = t.otherwise(lambda t: ...)
877
+
878
+ Execute callback if the value of :attr:`success` is false. ::
879
+
880
+ # Diagnostics
881
+ (
882
+ t.get_ok('/bad')
883
+ .otherwise(lambda t: diag('Must have been Glen!'))
884
+ .status_is(200)
885
+ .otherwise(lambda t: diag(t.tx.res.dom().at('title').text))
886
+ )
887
+ """
888
+ if not self.success:
889
+ cb(self)
890
+ return self
891
+
892
+ # Renamed from Mojolicious: or is otherwise, as or is a keyword of Python, and or_ is its alias
893
+ or_ = otherwise
894
+
895
+ def patch_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
896
+ """::
897
+
898
+ t = t.patch_ok('http://example.com/foo')
899
+ t = t.patch_ok('/foo')
900
+ t = t.patch_ok('/foo', {'Accept': '*/*'}, 'Content!')
901
+ t = t.patch_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
902
+ t = t.patch_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
903
+
904
+ Perform a ``PATCH`` request and check for transport errors, takes the
905
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.patch`, except
906
+ for the callback.
907
+ """
908
+ return self._build_ok('PATCH', url, *args, **kwargs)
909
+
910
+ def post_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
911
+ """::
912
+
913
+ t = t.post_ok('http://example.com/foo')
914
+ t = t.post_ok('/foo')
915
+ t = t.post_ok('/foo', {'Accept': '*/*'}, 'Content!')
916
+ t = t.post_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
917
+ t = t.post_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
918
+
919
+ Perform a ``POST`` request and check for transport errors, takes the
920
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.post`, except
921
+ for the callback. ::
922
+
923
+ # Test file upload
924
+ upload = {'foo': {'content': 'bar', 'filename': 'baz.txt'}}
925
+ t.post_ok('/upload', form=upload).status_is(200)
926
+
927
+ # Test JSON API
928
+ t.post_ok('/hello.json', json={'hello': 'world'}).status_is(200).json_is({'bye': 'world'})
929
+ """
930
+ return self._build_ok('POST', url, *args, **kwargs)
931
+
932
+ def post_sse_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
933
+ """::
934
+
935
+ t = t.post_sse_ok('http://example.com/events')
936
+ t = t.post_sse_ok('/events')
937
+ t = t.post_sse_ok('/events', {'Accept': 'text/event-stream'}, 'Content!')
938
+ t = t.post_sse_ok('/events', {'Accept': 'text/event-stream'}, form={'a': 'b'})
939
+ t = t.post_sse_ok('/events', {'Accept': 'text/event-stream'}, json={'a': 'b'})
940
+
941
+ Perform a ``POST`` request to establish a Server-Sent Events (SSE)
942
+ connection, takes the same arguments as
943
+ :meth:`Pyjo.UserAgent.Pyjo_UserAgent.post`, except for the callback.
944
+ Note that this method is **EXPERIMENTAL** and may change without
945
+ warning!
946
+ """
947
+ return self._build_sse_ok('POST', url, *args, **kwargs)
948
+
949
+ def put_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
950
+ """::
951
+
952
+ t = t.put_ok('http://example.com/foo')
953
+ t = t.put_ok('/foo')
954
+ t = t.put_ok('/foo', {'Accept': '*/*'}, 'Content!')
955
+ t = t.put_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
956
+ t = t.put_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
957
+
958
+ Perform a ``PUT`` request and check for transport errors, takes the
959
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.put`, except
960
+ for the callback.
961
+ """
962
+ return self._build_ok('PUT', url, *args, **kwargs)
963
+
964
+ def query_ok(self, url: Any, *args: Any, **kwargs: Any) -> Self:
965
+ """::
966
+
967
+ t = t.query_ok('http://example.com/foo')
968
+ t = t.query_ok('/foo')
969
+ t = t.query_ok('/foo', {'Accept': '*/*'}, 'Content!')
970
+ t = t.query_ok('/foo', {'Accept': '*/*'}, form={'a': 'b'})
971
+ t = t.query_ok('/foo', {'Accept': '*/*'}, json={'a': 'b'})
972
+
973
+ Perform a ``QUERY`` request and check for transport errors, takes the
974
+ same arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.query`, except
975
+ for the callback.
976
+ """
977
+ return self._build_ok('QUERY', url, *args, **kwargs)
978
+
979
+ def request_ok(self, tx: 'Pyjo.Transaction.HTTP.object') -> Self:
980
+ """::
981
+
982
+ t = t.request_ok(Pyjo.Transaction.HTTP.new())
983
+
984
+ Perform request and check for transport errors. ::
985
+
986
+ # Request with custom method
987
+ tx = t.ua.build_tx('FOO', '/test.json', json={'foo': 1})
988
+ t.request_ok(tx).status_is(200).json_is({'success': 1})
989
+
990
+ # Request with custom cookie
991
+ tx = t.ua.build_tx('GET', '/account')
992
+ tx.req.cookies({'name': 'user', 'value': 'sri'})
993
+ t.request_ok(tx).status_is(200).text_is('head > title', 'Hello sri')
994
+
995
+ # Custom WebSocket handshake
996
+ tx = t.ua.build_websocket_tx('/foo')
997
+ tx.req.headers.remove('User-Agent')
998
+ t.request_ok(tx).message_ok().message_is('bar').finish_ok()
999
+ """
1000
+ return self._request_ok(tx, tx.req.url.to_str())
1001
+
1002
+ def reset_session(self) -> Self:
1003
+ """::
1004
+
1005
+ t = t.reset_session()
1006
+
1007
+ Reset user agent session.
1008
+ """
1009
+ self.ua.cookie_jar.empty()
1010
+ self.tx = None
1011
+ return self
1012
+
1013
+ def send_ok(self, msg: Any, desc: str | None = None) -> Self:
1014
+ """::
1015
+
1016
+ t = t.send_ok({'binary': b'bytes'})
1017
+ t = t.send_ok({'text': b'bytes'})
1018
+ t = t.send_ok({'json': {'test': [1, 2, 3]}})
1019
+ t = t.send_ok([fin, rsv1, rsv2, rsv3, op, payload])
1020
+ t = t.send_ok('chars')
1021
+ t = t.send_ok('chars', 'sent successfully')
1022
+
1023
+ Send message or frame via WebSocket. ::
1024
+
1025
+ # Send JSON object as "Text" message
1026
+ (
1027
+ t.websocket_ok('/echo.json')
1028
+ .send_ok({'json': {'test': 'I ♥ Pyjoyment!'}})
1029
+ .message_ok()
1030
+ .json_message_is('/test', 'I ♥ Pyjoyment!')
1031
+ .finish_ok()
1032
+ )
1033
+ """
1034
+ desc = _desc(desc, 'send message')
1035
+ if not self._tx.is_websocket:
1036
+ return self.test('ok', 0, desc)
1037
+
1038
+ self._tx.send(msg, _stop)
1039
+ Pyjo.IOLoop.start()
1040
+ return self.test('ok', 1, desc)
1041
+
1042
+ def sse_finish_ok(self) -> Self:
1043
+ """::
1044
+
1045
+ t = t.sse_finish_ok()
1046
+
1047
+ Close Server-Sent Events connection. Note that this method is
1048
+ **EXPERIMENTAL** and may change without warning!
1049
+ """
1050
+ # Unlike Mojolicious: the error is interrupted by Pyjo.Test.Pyjo, where Mojo names Test::Mojo
1051
+ self._tx.res.error = {'message': 'Interrupted by Pyjo.Test.Pyjo'}
1052
+
1053
+ # Unlike Mojolicious: the connection is closed too, as the user agent finds the error only when it reads, so that
1054
+ # a stream without events, which Mojo waits for until its next event or its inactivity timeout, ends at once
1055
+ if (id := self._tx.connection) is not None and (stream := Pyjo.IOLoop.singleton.stream(id)) is not None:
1056
+ stream.close_gracefully()
1057
+ while not self._sse_finished:
1058
+ Pyjo.IOLoop.one_tick()
1059
+ return self.test('ok', 1, 'closed SSE connection')
1060
+
1061
+ def sse_finished_ok(self) -> Self:
1062
+ """::
1063
+
1064
+ t = t.sse_finished_ok()
1065
+
1066
+ Wait for Server-Sent Events connection to be closed. Note that this
1067
+ method is **EXPERIMENTAL** and may change without warning!
1068
+ """
1069
+ while not self._sse_finished:
1070
+ Pyjo.IOLoop.one_tick()
1071
+ return self.test('ok', 1, 'SSE connection has been closed')
1072
+
1073
+ def sse_ok(self, desc: str | None = None) -> Self:
1074
+ """::
1075
+
1076
+ t = t.sse_ok()
1077
+ t = t.sse_ok('got an event')
1078
+
1079
+ Wait for next Server-Sent Event to arrive. Note that this method is
1080
+ **EXPERIMENTAL** and may change without warning!
1081
+ """
1082
+ return self.test('ok', bool(self._sse_wait()), _desc(desc, 'event received'))
1083
+
1084
+ def sse_id_is(self, id: Any, desc: str | None = None) -> Self:
1085
+ """::
1086
+
1087
+ t = t.sse_id_is(123)
1088
+ t = t.sse_id_is(123, 'right id')
1089
+
1090
+ Check Server-Sent Event id for exact match. Note that this method is
1091
+ **EXPERIMENTAL** and may change without warning!
1092
+ """
1093
+ return self.test('is', self._event('id'), id, _desc(desc, 'exact match for SSE event id'))
1094
+
1095
+ def sse_id_isnt(self, id: Any, desc: str | None = None) -> Self:
1096
+ """::
1097
+
1098
+ t = t.sse_id_isnt(123)
1099
+ t = t.sse_id_isnt(123, 'different id')
1100
+
1101
+ Opposite of :meth:`sse_id_is`. Note that this method is
1102
+ **EXPERIMENTAL** and may change without warning!
1103
+ """
1104
+ return self.test('isnt', self._event('id'), id, _desc(desc, 'no match for SSE event id'))
1105
+
1106
+ def sse_text_is(self, text: Any, desc: str | None = None) -> Self:
1107
+ """::
1108
+
1109
+ t = t.sse_text_is('working!')
1110
+ t = t.sse_text_is('working!', 'right text')
1111
+
1112
+ Check Server-Sent Event text for exact match. Note that this method is
1113
+ **EXPERIMENTAL** and may change without warning!
1114
+ """
1115
+ return self.test('is', self._event('text'), text, _desc(desc, 'exact match for SSE event text'))
1116
+
1117
+ def sse_text_isnt(self, text: Any, desc: str | None = None) -> Self:
1118
+ """::
1119
+
1120
+ t = t.sse_text_isnt('working!')
1121
+ t = t.sse_text_isnt('working!', 'different text')
1122
+
1123
+ Opposite of :meth:`sse_text_is`. Note that this method is
1124
+ **EXPERIMENTAL** and may change without warning!
1125
+ """
1126
+ return self.test('isnt', self._event('text'), text, _desc(desc, 'no match for SSE event text'))
1127
+
1128
+ def sse_text_like(self, regex: Any, desc: str | None = None) -> Self:
1129
+ """::
1130
+
1131
+ t = t.sse_text_like(re.compile(r'working'))
1132
+ t = t.sse_text_like(re.compile(r'working'), 'right text')
1133
+
1134
+ Check Server-Sent Event text for exact match. Note that this method is
1135
+ **EXPERIMENTAL** and may change without warning!
1136
+ """
1137
+ return self.test('like', self._event('text'), regex, _desc(desc, 'similar match for SSE event text'))
1138
+
1139
+ def sse_text_unlike(self, regex: Any, desc: str | None = None) -> Self:
1140
+ """::
1141
+
1142
+ t = t.sse_text_unlike(re.compile(r'working'))
1143
+ t = t.sse_text_unlike(re.compile(r'working'), 'different text')
1144
+
1145
+ Opposite of :meth:`sse_text_like`. Note that this method is
1146
+ **EXPERIMENTAL** and may change without warning!
1147
+ """
1148
+ return self.test('unlike', self._event('text'), regex, _desc(desc, 'no similar match for SSE event text'))
1149
+
1150
+ def sse_type_is(self, type: Any, desc: str | None = None) -> Self:
1151
+ """::
1152
+
1153
+ t = t.sse_type_is('message')
1154
+ t = t.sse_type_is('message', 'right type')
1155
+
1156
+ Check Server-Sent Event type for exact match. Note that this method is
1157
+ **EXPERIMENTAL** and may change without warning!
1158
+ """
1159
+ return self.test('is', self._event('type'), type, _desc(desc, 'exact match for SSE event type'))
1160
+
1161
+ def sse_type_isnt(self, type: Any, desc: str | None = None) -> Self:
1162
+ """::
1163
+
1164
+ t = t.sse_type_isnt('message')
1165
+ t = t.sse_type_isnt('message', 'different type')
1166
+
1167
+ Opposite of :meth:`sse_type_is`. Note that this method is
1168
+ **EXPERIMENTAL** and may change without warning!
1169
+ """
1170
+ return self.test('isnt', self._event('type'), type, _desc(desc, 'no match for SSE event type'))
1171
+
1172
+ def status_is(self, status: int, desc: str | None = None) -> Self:
1173
+ """::
1174
+
1175
+ t = t.status_is(200)
1176
+ t = t.status_is(200, 'right status')
1177
+
1178
+ Check response status for exact match.
1179
+ """
1180
+ res = self._tx.res
1181
+ desc = _desc(desc, f'{status} {res.default_message(status)}')
1182
+ return self.test('is', res.code, status, desc)
1183
+
1184
+ def status_isnt(self, status: int, desc: str | None = None) -> Self:
1185
+ """::
1186
+
1187
+ t = t.status_isnt(200)
1188
+ t = t.status_isnt(200, 'different status')
1189
+
1190
+ Opposite of :meth:`status_is`.
1191
+ """
1192
+ res = self._tx.res
1193
+ desc = _desc(desc, f'not {status} {res.default_message(status)}')
1194
+ return self.test('isnt', res.code, status, desc)
1195
+
1196
+ def test(self, name: str, *args: Any) -> Self:
1197
+ """::
1198
+
1199
+ t = t.test('is', 'first value', 'second value', 'right value')
1200
+
1201
+ Call :mod:`Pyjo.Test` functions through :attr:`handler`, by the names
1202
+ of the functions of ``Test::More`` (``ok``, ``is``, ``isnt``,
1203
+ ``like``, ``unlike`` and ``is_deeply``), used to implement
1204
+ :mod:`Pyjo.Test.Pyjo` extensions. The result will be stored in
1205
+ :attr:`success`.
1206
+ """
1207
+ self.success = bool(self.handler(name, *args))
1208
+ return self
1209
+
1210
+ def text_is(self, selector: str, value: Any, desc: str | None = None) -> Self:
1211
+ """::
1212
+
1213
+ t = t.text_is('div.foo[x=y]', 'Hello!')
1214
+ t = t.text_is('html head title', 'Hello!', 'right title')
1215
+
1216
+ Checks text content of the CSS selectors first matching HTML/XML
1217
+ element for exact match with :meth:`Pyjo.DOM.Pyjo_DOM.at`.
1218
+ """
1219
+ return self.test('is', self._text(selector), value, _desc(desc, f'exact match for selector "{selector}"'))
1220
+
1221
+ def text_isnt(self, selector: str, value: Any, desc: str | None = None) -> Self:
1222
+ """::
1223
+
1224
+ t = t.text_isnt('div.foo[x=y]', 'Hello!')
1225
+ t = t.text_isnt('html head title', 'Hello!', 'different title')
1226
+
1227
+ Opposite of :meth:`text_is`.
1228
+ """
1229
+ return self.test('isnt', self._text(selector), value, _desc(desc, f'no match for selector "{selector}"'))
1230
+
1231
+ def text_like(self, selector: str, regex: Any, desc: str | None = None) -> Self:
1232
+ """::
1233
+
1234
+ t = t.text_like('div.foo[x=y]', re.compile(r'Hello'))
1235
+ t = t.text_like('html head title', re.compile(r'Hello'), 'right title')
1236
+
1237
+ Checks text content of the CSS selectors first matching HTML/XML
1238
+ element for similar match with :meth:`Pyjo.DOM.Pyjo_DOM.at`.
1239
+ """
1240
+ desc = _desc(desc, f'similar match for selector "{selector}"')
1241
+ return self.test('like', self._text(selector), regex, desc)
1242
+
1243
+ def text_unlike(self, selector: str, regex: Any, desc: str | None = None) -> Self:
1244
+ """::
1245
+
1246
+ t = t.text_unlike('div.foo[x=y]', re.compile(r'Hello'))
1247
+ t = t.text_unlike('html head title', re.compile(r'Hello'), 'different title')
1248
+
1249
+ Opposite of :meth:`text_like`.
1250
+ """
1251
+ desc = _desc(desc, f'no similar match for selector "{selector}"')
1252
+ return self.test('unlike', self._text(selector), regex, desc)
1253
+
1254
+ def websocket_ok(self, url: Any, *args: Any) -> Self:
1255
+ """::
1256
+
1257
+ t = t.websocket_ok('http://example.com/echo')
1258
+ t = t.websocket_ok('/echo')
1259
+ t = t.websocket_ok('/echo', {'DNT': 1}, ['v1.proto'])
1260
+
1261
+ Open a WebSocket connection with transparent handshake, takes the same
1262
+ arguments as :meth:`Pyjo.UserAgent.Pyjo_UserAgent.websocket`, except
1263
+ for the callback. ::
1264
+
1265
+ # WebSocket with permessage-deflate compression
1266
+ (
1267
+ t.websocket_ok('/', {'Sec-WebSocket-Extensions': 'permessage-deflate'})
1268
+ .send_ok('y' * 50000)
1269
+ .message_ok()
1270
+ .message_is('z' * 50000)
1271
+ .finish_ok()
1272
+ )
1273
+ """
1274
+ return self._request_ok(self.ua.build_websocket_tx(url, *args), url)
1275
+
1276
+ @property
1277
+ def _tx(self) -> Any:
1278
+ if self.tx is None:
1279
+ raise AttributeError('No transaction to test, make a request first')
1280
+ return self.tx
1281
+
1282
+ def _attr(self, selector: str, attr: str) -> str | None:
1283
+ if (e := self._dom().at(selector)) is None:
1284
+ return None
1285
+ value = e.attr(attr)
1286
+ return value if value is not None else ''
1287
+
1288
+ def _build_ok(self, method: str, url: Any, *args: Any, **kwargs: Any) -> Self:
1289
+ return self._request_ok(self.ua.build_tx(method, url, *args, **kwargs), url)
1290
+
1291
+ def _build_sse_ok(self, method: str, url: Any, *args: Any, **kwargs: Any) -> Self:
1292
+ return self._sse_ok(self.ua.build_tx(method, url, *args, **kwargs), url)
1293
+
1294
+ def _dom(self) -> Any:
1295
+ return self._tx.res.dom()
1296
+
1297
+ def _event(self, name: str) -> Any:
1298
+ return (self.sse or {}).get(name)
1299
+
1300
+ def _json(self, method: str, p: str) -> Any:
1301
+ message = self.message or []
1302
+ payload = message[1] if len(message) > 1 else None
1303
+ return getattr(Pyjo.JSON.Pointer.new(j(payload) if payload is not None else None), method)(p)
1304
+
1305
+ def _message(self, name: str, value: Any, desc: str) -> Self:
1306
+ type, msg = (self.message or [None, None])[:2]
1307
+
1308
+ # Type check
1309
+ if isinstance(value, dict):
1310
+ expect = 'text' if 'text' in value else 'binary'
1311
+ value = cast(dict[str, Any], value)[expect]
1312
+ if (type or '') != expect:
1313
+ msg = ''
1314
+
1315
+ # Decode text frame if there is no type check
1316
+ elif (type or '') == 'text':
1317
+ msg = decode('UTF-8', msg)
1318
+
1319
+ return self.test(name, msg if msg is not None else '', value, desc)
1320
+
1321
+ def _request_ok(self, tx: Any, url: Any) -> Self:
1322
+ # Establish WebSocket connection
1323
+ if tx.req.is_handshake:
1324
+ self._finished, self._messages = None, []
1325
+
1326
+ def handshake(ua: Any, tx: Any) -> None:
1327
+ self.tx = tx
1328
+ if not tx.is_websocket:
1329
+ self._finished = []
1330
+
1331
+ def finish(tx: Any, *args: Any) -> None:
1332
+ self._finished = list(args)
1333
+
1334
+ def binary(tx: Any, msg: bytes) -> None:
1335
+ self._messages.append(['binary', msg])
1336
+
1337
+ def text(tx: Any, msg: bytes) -> None:
1338
+ self._messages.append(['text', msg])
1339
+
1340
+ tx.on('finish', finish)
1341
+ tx.on('binary', binary)
1342
+ tx.on('text', text)
1343
+ Pyjo.IOLoop.stop()
1344
+
1345
+ self.ua.start(tx, handshake)
1346
+ Pyjo.IOLoop.start()
1347
+
1348
+ return self.test('ok', self._tx.is_websocket, _desc(None, f'WebSocket handshake with {url}'))
1349
+
1350
+ # Perform request
1351
+ self.tx = self.ua.start(tx)
1352
+ err = self._tx.error
1353
+ if not (ok := not err or not err.get('message') or err.get('code')) and err:
1354
+ Pyjo.Test.diag(err['message'])
1355
+ return self.test('ok', bool(ok), _desc(None, f'{tx.req.method.upper()} {url}'))
1356
+
1357
+ def _sse_ok(self, tx: Any, url: Any) -> Self:
1358
+ ok = None
1359
+ self._sse_finished, self._sse_events = None, []
1360
+
1361
+ def sse(content: Any, event: dict[str, Any] | None) -> None:
1362
+ nonlocal ok
1363
+ if event:
1364
+ self._sse_events.append(event)
1365
+ else:
1366
+ ok = 1
1367
+ Pyjo.IOLoop.stop()
1368
+
1369
+ tx.res.content.on('sse', sse)
1370
+
1371
+ def start(ua: Any, tx: Any) -> None:
1372
+ self.tx = tx
1373
+
1374
+ cb = self.ua.on('start', start)
1375
+
1376
+ def finish(ua: Any, tx: Any) -> None:
1377
+ self._sse_finished = True
1378
+ self.ua.unsubscribe('start', cb)
1379
+ Pyjo.IOLoop.stop()
1380
+
1381
+ self.ua.start(tx, finish)
1382
+ Pyjo.IOLoop.start()
1383
+
1384
+ return self.test('ok', ok, _desc(None, f'SSE connection established: {tx.req.method.upper()} {url}'))
1385
+
1386
+ def _sse_wait(self) -> dict[str, Any] | None:
1387
+ while not self._sse_finished and not len(self._sse_events):
1388
+ Pyjo.IOLoop.one_tick()
1389
+ self.sse = self._sse_events.pop(0) if len(self._sse_events) else None
1390
+ return self.sse
1391
+
1392
+ def _text(self, selector: str) -> str | None:
1393
+ if (e := self._dom().at(selector)) is None:
1394
+ return None
1395
+ return e.text
1396
+
1397
+ def _wait(self) -> list[Any] | None:
1398
+ while self._finished is None and not len(self._messages):
1399
+ Pyjo.IOLoop.one_tick()
1400
+ self.message = self._messages.pop(0) if len(self._messages) else None
1401
+ return self.message
1402
+
1403
+
1404
+ # Unlike Mojolicious: the strings are characters, which Mojo encodes to UTF-8 for Test::More
1405
+ def _desc(desc: str | None, default: str) -> str:
1406
+ return desc or default
1407
+
1408
+
1409
+ # A regular expression of Pyjo.Test
1410
+ type _Pattern = str | bytes | re.Pattern[str] | re.Pattern[bytes] | tuple[str | bytes, str]
1411
+
1412
+
1413
+ # like and unlike of Test::More, which match the string of a value, as a number, and match bytes, the characters
1414
+ # 0-255 of Perl, with a pattern of characters
1415
+ def _like(function: Callable[..., Pyjo.Test.Result]) -> Callable[..., Pyjo.Test.Result]:
1416
+ def like(got: Any, regex: _Pattern, desc: str | None = None) -> Pyjo.Test.Result:
1417
+ pattern = regex.pattern if isinstance(regex, re.Pattern) else regex[0] if isinstance(regex, tuple) else regex
1418
+ if isinstance(got, bytes) and not isinstance(pattern, bytes):
1419
+ got = got.decode('latin-1')
1420
+ elif got is not None and not isinstance(got, str | bytes):
1421
+ got = str(got)
1422
+ return function(got, regex, desc)
1423
+
1424
+ return like
1425
+
1426
+
1427
+ # The functions of Pyjo.Test by the names of the functions of Test::More
1428
+ _FUNCTIONS: dict[str, Callable[..., Any]] = {
1429
+ 'is': Pyjo.Test.is_ok,
1430
+ 'is_deeply': Pyjo.Test.is_deeply_ok,
1431
+ 'isnt': Pyjo.Test.isnt_ok,
1432
+ 'like': _like(Pyjo.Test.like_ok),
1433
+ 'ok': Pyjo.Test.ok,
1434
+ 'unlike': _like(Pyjo.Test.unlike_ok),
1435
+ }
1436
+
1437
+
1438
+ def _handler(name: str, *args: Any) -> Any:
1439
+ return _FUNCTIONS[name](*args)
1440
+
1441
+
1442
+ def _json_args(args: tuple[Any, ...]) -> tuple[str, Any, str | None]:
1443
+ values = list(args)
1444
+ if len(values) > 1:
1445
+ return values[0], values[1], values[2] if len(values) > 2 else None
1446
+ return '', values[0] if len(values) else None, None
1447
+
1448
+
1449
+ def _stop(tx: Any) -> None:
1450
+ Pyjo.IOLoop.stop()
1451
+
1452
+
1453
+ new = Pyjo_Test_Pyjo.new
1454
+ object = Pyjo_Test_Pyjo