@pyscript/core 0.1.18 → 0.1.20
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.
- package/README.md +19 -2
- package/dist/core.js +3 -3
- package/dist/core.js.map +1 -1
- package/dist/error-87e0706c.js +2 -0
- package/dist/error-87e0706c.js.map +1 -0
- package/docs/README.md +3 -12
- package/package.json +4 -3
- package/src/config.js +110 -0
- package/src/core.js +84 -106
- package/src/fetch.js +3 -0
- package/src/plugins/error.js +1 -1
- package/src/plugins.js +1 -1
- package/src/stdlib/pyscript/__init__.py +34 -0
- package/src/stdlib/pyscript/display.py +154 -0
- package/src/stdlib/pyscript/event_handling.py +45 -0
- package/src/stdlib/pyscript/magic_js.py +32 -0
- package/src/stdlib/pyscript/util.py +22 -0
- package/src/stdlib/pyscript.js +9 -5
- package/src/stdlib/pyweb/pydom.py +314 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +184 -0
- package/tests/integration/support.py +1038 -0
- package/tests/integration/test_00_support.py +495 -0
- package/tests/integration/test_01_basic.py +353 -0
- package/tests/integration/test_02_display.py +452 -0
- package/tests/integration/test_03_element.py +303 -0
- package/tests/integration/test_assets/line_plot.png +0 -0
- package/tests/integration/test_assets/tripcolor.png +0 -0
- package/tests/integration/test_async.py +197 -0
- package/tests/integration/test_event_handling.py +193 -0
- package/tests/integration/test_importmap.py +66 -0
- package/tests/integration/test_interpreter.py +98 -0
- package/tests/integration/test_plugins.py +419 -0
- package/tests/integration/test_py_config.py +294 -0
- package/tests/integration/test_py_repl.py +663 -0
- package/tests/integration/test_py_terminal.py +270 -0
- package/tests/integration/test_runtime_attributes.py +64 -0
- package/tests/integration/test_script_type.py +121 -0
- package/tests/integration/test_shadow_root.py +33 -0
- package/tests/integration/test_splashscreen.py +124 -0
- package/tests/integration/test_stdio_handling.py +370 -0
- package/tests/integration/test_style.py +47 -0
- package/tests/integration/test_warnings_and_banners.py +32 -0
- package/tests/integration/test_zz_examples.py +419 -0
- package/tests/integration/test_zzz_docs_snippets.py +305 -0
- package/types/config.d.ts +3 -0
- package/types/core.d.ts +1 -2
- package/types/fetch.d.ts +1 -0
- package/types/plugins/error.d.ts +1 -1
- package/types/stdlib/pyscript.d.ts +8 -4
- package/dist/error-91f1c2f6.js +0 -2
- package/dist/error-91f1c2f6.js.map +0 -1
@@ -0,0 +1,1038 @@
|
|
1
|
+
import dataclasses
|
2
|
+
import functools
|
3
|
+
import math
|
4
|
+
import os
|
5
|
+
import pdb
|
6
|
+
import re
|
7
|
+
import sys
|
8
|
+
import time
|
9
|
+
import traceback
|
10
|
+
import urllib
|
11
|
+
from dataclasses import dataclass
|
12
|
+
|
13
|
+
import py
|
14
|
+
import pytest
|
15
|
+
import toml
|
16
|
+
from playwright.sync_api import Error as PlaywrightError
|
17
|
+
|
18
|
+
ROOT = py.path.local(__file__).dirpath("..", "..", "..")
|
19
|
+
BUILD = ROOT.join("pyscript.core").join("dist")
|
20
|
+
|
21
|
+
|
22
|
+
def params_with_marks(params):
|
23
|
+
"""
|
24
|
+
Small helper to automatically apply to each param a pytest.mark with the
|
25
|
+
same name of the param itself. E.g.:
|
26
|
+
|
27
|
+
params_with_marks(['aaa', 'bbb'])
|
28
|
+
|
29
|
+
is equivalent to:
|
30
|
+
|
31
|
+
[pytest.param('aaa', marks=pytest.mark.aaa),
|
32
|
+
pytest.param('bbb', marks=pytest.mark.bbb)]
|
33
|
+
|
34
|
+
This makes it possible to use 'pytest -m aaa' to run ONLY the tests which
|
35
|
+
uses the param 'aaa'.
|
36
|
+
"""
|
37
|
+
return [pytest.param(name, marks=getattr(pytest.mark, name)) for name in params]
|
38
|
+
|
39
|
+
|
40
|
+
def with_execution_thread(*values):
|
41
|
+
"""
|
42
|
+
Class decorator to override config.execution_thread.
|
43
|
+
|
44
|
+
By default, we run each test twice:
|
45
|
+
- execution_thread = 'main'
|
46
|
+
- execution_thread = 'worker'
|
47
|
+
|
48
|
+
If you want to execute certain tests with only one specific values of
|
49
|
+
execution_thread, you can use this class decorator. For example:
|
50
|
+
|
51
|
+
@with_execution_thread('main')
|
52
|
+
class TestOnlyMainThread:
|
53
|
+
...
|
54
|
+
|
55
|
+
@with_execution_thread('worker')
|
56
|
+
class TestOnlyWorker:
|
57
|
+
...
|
58
|
+
|
59
|
+
If you use @with_execution_thread(None), the logic to inject the
|
60
|
+
execution_thread config is disabled.
|
61
|
+
"""
|
62
|
+
|
63
|
+
if values == (None,):
|
64
|
+
|
65
|
+
@pytest.fixture
|
66
|
+
def execution_thread(self, request):
|
67
|
+
return None
|
68
|
+
|
69
|
+
else:
|
70
|
+
for value in values:
|
71
|
+
assert value in ("main", "worker")
|
72
|
+
|
73
|
+
@pytest.fixture(params=params_with_marks(values))
|
74
|
+
def execution_thread(self, request):
|
75
|
+
return request.param
|
76
|
+
|
77
|
+
def with_execution_thread_decorator(cls):
|
78
|
+
cls.execution_thread = execution_thread
|
79
|
+
return cls
|
80
|
+
|
81
|
+
return with_execution_thread_decorator
|
82
|
+
|
83
|
+
|
84
|
+
def skip_worker(reason):
|
85
|
+
"""
|
86
|
+
Decorator to skip a test if self.execution_thread == 'worker'
|
87
|
+
"""
|
88
|
+
if callable(reason):
|
89
|
+
# this happens if you use @skip_worker instead of @skip_worker("bla bla bla")
|
90
|
+
raise Exception(
|
91
|
+
"You need to specify a reason for skipping, "
|
92
|
+
"please use: @skip_worker('...')"
|
93
|
+
)
|
94
|
+
|
95
|
+
def decorator(fn):
|
96
|
+
@functools.wraps(fn)
|
97
|
+
def decorated(self, *args):
|
98
|
+
if self.execution_thread == "worker":
|
99
|
+
pytest.skip(reason)
|
100
|
+
return fn(self, *args)
|
101
|
+
|
102
|
+
return decorated
|
103
|
+
|
104
|
+
return decorator
|
105
|
+
|
106
|
+
|
107
|
+
def filter_inner_text(text, exclude=None):
|
108
|
+
return "\n".join(filter_page_content(text.splitlines(), exclude=exclude))
|
109
|
+
|
110
|
+
|
111
|
+
def filter_page_content(lines, exclude=None):
|
112
|
+
"""Remove lines that are not relevant for the test. By default, ignores:
|
113
|
+
('', 'execution_thread = "main"', 'execution_thread = "worker"')
|
114
|
+
|
115
|
+
Args:
|
116
|
+
lines (list): list of strings
|
117
|
+
exclude (list): list of strings to exclude
|
118
|
+
|
119
|
+
Returns:
|
120
|
+
list: list of strings
|
121
|
+
"""
|
122
|
+
if exclude is None:
|
123
|
+
exclude = {"", 'execution_thread = "main"', 'execution_thread = "worker"'}
|
124
|
+
|
125
|
+
return [line for line in lines if line not in exclude]
|
126
|
+
|
127
|
+
|
128
|
+
@pytest.mark.usefixtures("init")
|
129
|
+
@with_execution_thread("main", "worker")
|
130
|
+
class PyScriptTest:
|
131
|
+
"""
|
132
|
+
Base class to write PyScript integration tests, based on playwright.
|
133
|
+
|
134
|
+
It provides a simple API to generate HTML files and load them in
|
135
|
+
playwright.
|
136
|
+
|
137
|
+
It also provides a Pythonic API on top of playwright for the most
|
138
|
+
common tasks; in particular:
|
139
|
+
|
140
|
+
- self.console collects all the JS console.* messages. Look at the doc
|
141
|
+
of ConsoleMessageCollection for more details.
|
142
|
+
|
143
|
+
- self.check_js_errors() checks that no JS errors have been thrown
|
144
|
+
|
145
|
+
- after each test, self.check_js_errors() is automatically run to ensure
|
146
|
+
that no JS error passes uncaught.
|
147
|
+
|
148
|
+
- self.wait_for_console waits until the specified message appears in the
|
149
|
+
console
|
150
|
+
|
151
|
+
- self.wait_for_pyscript waits until all the PyScript tags have been
|
152
|
+
evaluated
|
153
|
+
|
154
|
+
- self.pyscript_run is the main entry point for pyscript tests: it
|
155
|
+
creates an HTML page to run the specified snippet.
|
156
|
+
"""
|
157
|
+
|
158
|
+
DEFAULT_TIMEOUT = 20000
|
159
|
+
|
160
|
+
@pytest.fixture()
|
161
|
+
def init(self, request, tmpdir, logger, page, execution_thread):
|
162
|
+
"""
|
163
|
+
Fixture to automatically initialize all the tests in this class and its
|
164
|
+
subclasses.
|
165
|
+
|
166
|
+
The magic is done by the decorator @pytest.mark.usefixtures("init"),
|
167
|
+
which tells pytest to automatically use this fixture for all the test
|
168
|
+
method of this class.
|
169
|
+
|
170
|
+
Using the standard pytest behavior, we can request more fixtures:
|
171
|
+
tmpdir, and page; 'page' is a fixture provided by pytest-playwright.
|
172
|
+
|
173
|
+
Then, we save these fixtures on the self and proceed with more
|
174
|
+
initialization. The end result is that the requested fixtures are
|
175
|
+
automatically made available as self.xxx in all methods.
|
176
|
+
"""
|
177
|
+
self.testname = request.function.__name__.replace("test_", "")
|
178
|
+
self.tmpdir = tmpdir
|
179
|
+
# create a symlink to BUILD inside tmpdir
|
180
|
+
tmpdir.join("build").mksymlinkto(BUILD)
|
181
|
+
self.tmpdir.chdir()
|
182
|
+
self.logger = logger
|
183
|
+
self.execution_thread = execution_thread
|
184
|
+
self.dev_server = None
|
185
|
+
|
186
|
+
if request.config.option.no_fake_server:
|
187
|
+
# use a real HTTP server. Note that as soon as we request the
|
188
|
+
# fixture, the server automatically starts in its own thread.
|
189
|
+
self.dev_server = request.getfixturevalue("dev_server")
|
190
|
+
self.http_server_addr = self.dev_server.base_url
|
191
|
+
self.router = None
|
192
|
+
else:
|
193
|
+
# use the internal playwright routing
|
194
|
+
self.http_server_addr = "https://fake_server"
|
195
|
+
self.router = SmartRouter(
|
196
|
+
"fake_server",
|
197
|
+
cache=request.config.cache,
|
198
|
+
logger=logger,
|
199
|
+
usepdb=request.config.option.usepdb,
|
200
|
+
)
|
201
|
+
self.router.install(page)
|
202
|
+
#
|
203
|
+
self.init_page(page)
|
204
|
+
#
|
205
|
+
# this extra print is useful when using pytest -s, else we start printing
|
206
|
+
# in the middle of the line
|
207
|
+
print()
|
208
|
+
#
|
209
|
+
# if you use pytest --headed you can see the browser page while
|
210
|
+
# playwright executes the tests, but the page is closed very quickly
|
211
|
+
# as soon as the test finishes. To avoid that, we automatically start
|
212
|
+
# a pdb so that we can wait as long as we want.
|
213
|
+
yield
|
214
|
+
if request.config.option.headed:
|
215
|
+
pdb.Pdb.intro = (
|
216
|
+
"\n"
|
217
|
+
"This (Pdb) was started automatically because you passed --headed:\n"
|
218
|
+
"the execution of the test pauses here to give you the time to inspect\n"
|
219
|
+
"the browser. When you are done, type one of the following commands:\n"
|
220
|
+
" (Pdb) continue\n"
|
221
|
+
" (Pdb) cont\n"
|
222
|
+
" (Pdb) c\n"
|
223
|
+
)
|
224
|
+
pdb.set_trace()
|
225
|
+
|
226
|
+
def init_page(self, page):
|
227
|
+
self.page = page
|
228
|
+
|
229
|
+
# set default timeout to 60000 millliseconds from 30000
|
230
|
+
page.set_default_timeout(self.DEFAULT_TIMEOUT)
|
231
|
+
|
232
|
+
self.console = ConsoleMessageCollection(self.logger)
|
233
|
+
self._js_errors = []
|
234
|
+
self._py_errors = []
|
235
|
+
page.on("console", self._on_console)
|
236
|
+
page.on("pageerror", self._on_pageerror)
|
237
|
+
|
238
|
+
@property
|
239
|
+
def headers(self):
|
240
|
+
if self.dev_server is None:
|
241
|
+
return self.router.headers
|
242
|
+
return self.dev_server.RequestHandlerClass.my_headers()
|
243
|
+
|
244
|
+
def disable_cors_headers(self):
|
245
|
+
if self.dev_server is None:
|
246
|
+
self.router.enable_cors_headers = False
|
247
|
+
else:
|
248
|
+
self.dev_server.RequestHandlerClass.enable_cors_headers = False
|
249
|
+
|
250
|
+
def run_js(self, code):
|
251
|
+
"""
|
252
|
+
allows top level await to be present in the `code` parameter
|
253
|
+
"""
|
254
|
+
self.page.evaluate(
|
255
|
+
"""(async () => {
|
256
|
+
try {%s}
|
257
|
+
catch(e) {
|
258
|
+
console.error(e);
|
259
|
+
}
|
260
|
+
})();"""
|
261
|
+
% code
|
262
|
+
)
|
263
|
+
|
264
|
+
def teardown_method(self):
|
265
|
+
# we call check_js_errors on teardown: this means that if there are still
|
266
|
+
# non-cleared errors, the test will fail. If you expect errors in your
|
267
|
+
# page and they should not cause the test to fail, you should call
|
268
|
+
# self.check_js_errors() in the test itself.
|
269
|
+
self.check_js_errors()
|
270
|
+
self.check_py_errors()
|
271
|
+
|
272
|
+
def _on_console(self, msg):
|
273
|
+
if msg.type == "error" and "Traceback (most recent call last)" in msg.text:
|
274
|
+
# this is a Python traceback, let's record it as a py_error
|
275
|
+
self._py_errors.append(msg.text)
|
276
|
+
self.console.add_message(msg.type, msg.text)
|
277
|
+
|
278
|
+
def _on_pageerror(self, error):
|
279
|
+
# apparently, playwright Error.stack contains all the info that we
|
280
|
+
# want: exception name, message and stacktrace. The docs say that
|
281
|
+
# error.stack is optional, so fallback to the standard repr if it's
|
282
|
+
# unavailable.
|
283
|
+
error_msg = error.stack or str(error)
|
284
|
+
self.console.add_message("js_error", error_msg)
|
285
|
+
self._js_errors.append(error_msg)
|
286
|
+
|
287
|
+
def _check_page_errors(self, kind, expected_messages):
|
288
|
+
"""
|
289
|
+
Check whether the page raised any 'JS' or 'Python' error.
|
290
|
+
|
291
|
+
expected_messages is a list of strings of errors that you expect they
|
292
|
+
were raised in the page. They are checked using a simple 'in' check,
|
293
|
+
equivalent to this:
|
294
|
+
if expected_message in actual_error_message:
|
295
|
+
...
|
296
|
+
|
297
|
+
If an error was expected but not found, it raises PageErrorsDidNotRaise.
|
298
|
+
|
299
|
+
If there are MORE errors other than the expected ones, it raises PageErrors.
|
300
|
+
|
301
|
+
Upon return, all the errors are cleared, so a subsequent call to
|
302
|
+
check_{js,py}_errors will not raise, unless NEW errors have been reported
|
303
|
+
in the meantime.
|
304
|
+
"""
|
305
|
+
assert kind in ("JS", "Python")
|
306
|
+
if kind == "JS":
|
307
|
+
actual_errors = self._js_errors[:]
|
308
|
+
else:
|
309
|
+
actual_errors = self._py_errors[:]
|
310
|
+
expected_messages = list(expected_messages)
|
311
|
+
|
312
|
+
for i, msg in enumerate(expected_messages):
|
313
|
+
for j, error in enumerate(actual_errors):
|
314
|
+
if msg is not None and error is not None and msg in error:
|
315
|
+
# we matched one expected message with an error, remove both
|
316
|
+
expected_messages[i] = None
|
317
|
+
actual_errors[j] = None
|
318
|
+
|
319
|
+
# if everything is find, now expected_messages and actual_errors contains
|
320
|
+
# only Nones. If they contain non-None elements, it means that we
|
321
|
+
# either have messages which are expected-but-not-found or
|
322
|
+
# found-but-not-expected.
|
323
|
+
not_found = [msg for msg in expected_messages if msg is not None]
|
324
|
+
unexpected = [err for err in actual_errors if err is not None]
|
325
|
+
|
326
|
+
if kind == "JS":
|
327
|
+
self.clear_js_errors()
|
328
|
+
else:
|
329
|
+
self.clear_py_errors()
|
330
|
+
|
331
|
+
if not_found:
|
332
|
+
# expected-but-not-found
|
333
|
+
raise PageErrorsDidNotRaise(kind, not_found, unexpected)
|
334
|
+
if unexpected:
|
335
|
+
# found-but-not-expected
|
336
|
+
raise PageErrors(kind, unexpected)
|
337
|
+
|
338
|
+
def check_js_errors(self, *expected_messages):
|
339
|
+
"""
|
340
|
+
Check whether JS errors were reported.
|
341
|
+
|
342
|
+
See the docstring for _check_page_errors for more details.
|
343
|
+
"""
|
344
|
+
self._check_page_errors("JS", expected_messages)
|
345
|
+
|
346
|
+
def check_py_errors(self, *expected_messages):
|
347
|
+
"""
|
348
|
+
Check whether Python errors were reported.
|
349
|
+
|
350
|
+
See the docstring for _check_page_errors for more details.
|
351
|
+
"""
|
352
|
+
self._check_page_errors("Python", expected_messages)
|
353
|
+
|
354
|
+
def clear_js_errors(self):
|
355
|
+
"""
|
356
|
+
Clear all JS errors.
|
357
|
+
"""
|
358
|
+
self._js_errors = []
|
359
|
+
|
360
|
+
def clear_py_errors(self):
|
361
|
+
self._py_errors = []
|
362
|
+
|
363
|
+
def writefile(self, filename, content):
|
364
|
+
"""
|
365
|
+
Very thin helper to write a file in the tmpdir
|
366
|
+
"""
|
367
|
+
f = self.tmpdir.join(filename)
|
368
|
+
f.dirpath().ensure(dir=True)
|
369
|
+
f.write(content)
|
370
|
+
|
371
|
+
def goto(self, path):
|
372
|
+
self.logger.reset()
|
373
|
+
self.logger.log("page.goto", path, color="yellow")
|
374
|
+
url = f"{self.http_server_addr}/{path}"
|
375
|
+
self.page.goto(url, timeout=0)
|
376
|
+
|
377
|
+
def wait_for_console(
|
378
|
+
self, text, *, match_substring=False, timeout=None, check_js_errors=True
|
379
|
+
):
|
380
|
+
"""
|
381
|
+
Wait until the given message appear in the console. If the message was
|
382
|
+
already printed in the console, return immediately.
|
383
|
+
|
384
|
+
By default "text" must be the *exact* string as printed by a single
|
385
|
+
call to e.g. console.log. If match_substring is True, it is enough
|
386
|
+
that the console contains the given text anywhere.
|
387
|
+
|
388
|
+
timeout is expressed in milliseconds. If it's None, it will use
|
389
|
+
the same default as playwright, which is 30 seconds.
|
390
|
+
|
391
|
+
If check_js_errors is True (the default), it also checks that no JS
|
392
|
+
errors were raised during the waiting.
|
393
|
+
|
394
|
+
Return the elapsed time in ms.
|
395
|
+
"""
|
396
|
+
if match_substring:
|
397
|
+
|
398
|
+
def find_text():
|
399
|
+
return text in self.console.all.text
|
400
|
+
|
401
|
+
else:
|
402
|
+
|
403
|
+
def find_text():
|
404
|
+
return text in self.console.all.lines
|
405
|
+
|
406
|
+
if timeout is None:
|
407
|
+
timeout = 10 * 1000
|
408
|
+
# NOTE: we cannot use playwright's own page.expect_console_message(),
|
409
|
+
# because if you call it AFTER the text has already been emitted, it
|
410
|
+
# waits forever. Instead, we have to use our own custom logic.
|
411
|
+
try:
|
412
|
+
t0 = time.time()
|
413
|
+
while True:
|
414
|
+
elapsed_ms = (time.time() - t0) * 1000
|
415
|
+
if elapsed_ms > timeout:
|
416
|
+
raise TimeoutError(f"{elapsed_ms:.2f} ms")
|
417
|
+
#
|
418
|
+
if find_text():
|
419
|
+
# found it!
|
420
|
+
return elapsed_ms
|
421
|
+
#
|
422
|
+
self.page.wait_for_timeout(50)
|
423
|
+
finally:
|
424
|
+
# raise JsError if there were any javascript exception. Note that
|
425
|
+
# this might happen also in case of a TimeoutError. In that case,
|
426
|
+
# the JsError will shadow the TimeoutError but this is correct,
|
427
|
+
# because it's very likely that the console message never appeared
|
428
|
+
# precisely because of the exception in JS.
|
429
|
+
if check_js_errors:
|
430
|
+
self.check_js_errors()
|
431
|
+
|
432
|
+
def wait_for_pyscript(self, *, timeout=None, check_js_errors=True):
|
433
|
+
"""
|
434
|
+
Wait until pyscript has been fully loaded.
|
435
|
+
|
436
|
+
Timeout is expressed in milliseconds. If it's None, it will use
|
437
|
+
playwright's own default value, which is 30 seconds).
|
438
|
+
|
439
|
+
If check_js_errors is True (the default), it also checks that no JS
|
440
|
+
errors were raised during the waiting.
|
441
|
+
"""
|
442
|
+
# this is printed by interpreter.ts:Interpreter.initialize
|
443
|
+
elapsed_ms = self.wait_for_console(
|
444
|
+
"[pyscript/main] PyScript Ready",
|
445
|
+
timeout=timeout,
|
446
|
+
check_js_errors=check_js_errors,
|
447
|
+
)
|
448
|
+
self.logger.log(
|
449
|
+
"wait_for_pyscript", f"Waited for {elapsed_ms/1000:.2f} s", color="yellow"
|
450
|
+
)
|
451
|
+
# We still don't know why this wait is necessary, but without it
|
452
|
+
# events aren't being triggered in the tests.
|
453
|
+
self.page.wait_for_timeout(100)
|
454
|
+
|
455
|
+
def _parse_py_config(self, doc):
|
456
|
+
configs = re.findall("<py-config>(.*?)</py-config>", doc, flags=re.DOTALL)
|
457
|
+
configs = [cfg.strip() for cfg in configs]
|
458
|
+
if len(configs) == 0:
|
459
|
+
return None
|
460
|
+
elif len(configs) == 1:
|
461
|
+
return toml.loads(configs[0])
|
462
|
+
else:
|
463
|
+
raise AssertionError("Too many <py-config>")
|
464
|
+
|
465
|
+
def _inject_execution_thread_config(self, snippet, execution_thread):
|
466
|
+
"""
|
467
|
+
If snippet already contains a py-config, let's try to inject
|
468
|
+
execution_thread automatically. Note that this works only for plain
|
469
|
+
<py-config> with inline config: type="json" and src="..." are not
|
470
|
+
supported by this logic, which should remain simple.
|
471
|
+
"""
|
472
|
+
cfg = self._parse_py_config(snippet)
|
473
|
+
if cfg is None:
|
474
|
+
# we don't have any <py-config>, let's add one
|
475
|
+
py_config_maybe = f"""
|
476
|
+
<py-config>
|
477
|
+
execution_thread = "{execution_thread}"
|
478
|
+
</py-config>
|
479
|
+
"""
|
480
|
+
else:
|
481
|
+
cfg["execution_thread"] = execution_thread
|
482
|
+
dumped_cfg = toml.dumps(cfg)
|
483
|
+
new_py_config = f"""
|
484
|
+
<py-config>
|
485
|
+
{dumped_cfg}
|
486
|
+
</py-config>
|
487
|
+
"""
|
488
|
+
snippet = re.sub(
|
489
|
+
"<py-config>.*</py-config>", new_py_config, snippet, flags=re.DOTALL
|
490
|
+
)
|
491
|
+
# no need for extra config, it's already in the snippet
|
492
|
+
py_config_maybe = ""
|
493
|
+
#
|
494
|
+
return snippet, py_config_maybe
|
495
|
+
|
496
|
+
def _pyscript_format(self, snippet, *, execution_thread, extra_head=""):
|
497
|
+
if execution_thread is None:
|
498
|
+
py_config_maybe = ""
|
499
|
+
else:
|
500
|
+
snippet, py_config_maybe = self._inject_execution_thread_config(
|
501
|
+
snippet, execution_thread
|
502
|
+
)
|
503
|
+
|
504
|
+
doc = f"""
|
505
|
+
<html>
|
506
|
+
<head>
|
507
|
+
<link rel="stylesheet" href="{self.http_server_addr}/build/core.css">
|
508
|
+
<script
|
509
|
+
type="module"
|
510
|
+
src="{self.http_server_addr}/build/core.js"
|
511
|
+
></script>
|
512
|
+
{extra_head}
|
513
|
+
</head>
|
514
|
+
<body>
|
515
|
+
{py_config_maybe}
|
516
|
+
{snippet}
|
517
|
+
</body>
|
518
|
+
</html>
|
519
|
+
"""
|
520
|
+
return doc
|
521
|
+
|
522
|
+
def pyscript_run(
|
523
|
+
self,
|
524
|
+
snippet,
|
525
|
+
*,
|
526
|
+
extra_head="",
|
527
|
+
wait_for_pyscript=True,
|
528
|
+
timeout=None,
|
529
|
+
check_js_errors=True,
|
530
|
+
):
|
531
|
+
"""
|
532
|
+
Main entry point for pyscript tests.
|
533
|
+
|
534
|
+
snippet contains a fragment of HTML which will be put inside a full
|
535
|
+
HTML document. In particular, the <head> automatically contains the
|
536
|
+
correct <script> and <link> tags which are necessary to load pyscript
|
537
|
+
correctly.
|
538
|
+
|
539
|
+
This method does the following:
|
540
|
+
- write a full HTML file containing the snippet
|
541
|
+
- open a playwright page for it
|
542
|
+
- wait until pyscript has been fully loaded
|
543
|
+
"""
|
544
|
+
doc = self._pyscript_format(
|
545
|
+
snippet, execution_thread=self.execution_thread, extra_head=extra_head
|
546
|
+
)
|
547
|
+
if not wait_for_pyscript and timeout is not None:
|
548
|
+
raise ValueError("Cannot set a timeout if wait_for_pyscript=False")
|
549
|
+
filename = f"{self.testname}.html"
|
550
|
+
self.writefile(filename, doc)
|
551
|
+
self.goto(filename)
|
552
|
+
if wait_for_pyscript:
|
553
|
+
self.wait_for_pyscript(timeout=timeout, check_js_errors=check_js_errors)
|
554
|
+
|
555
|
+
def iter_locator(self, loc):
|
556
|
+
"""
|
557
|
+
Helper method to iterate over all the elements which are matched by a
|
558
|
+
locator, since playwright does not seem to support it natively.
|
559
|
+
"""
|
560
|
+
n = loc.count()
|
561
|
+
elems = [loc.nth(i) for i in range(n)]
|
562
|
+
return iter(elems)
|
563
|
+
|
564
|
+
def assert_no_banners(self):
|
565
|
+
"""
|
566
|
+
Ensure that there are no alert banners on the page, which are used for
|
567
|
+
errors and warnings. Raise AssertionError if any if found.
|
568
|
+
"""
|
569
|
+
loc = self.page.locator(".alert-banner")
|
570
|
+
n = loc.count()
|
571
|
+
if n > 0:
|
572
|
+
text = "\n".join(loc.all_inner_texts())
|
573
|
+
raise AssertionError(f"Found {n} alert banners:\n" + text)
|
574
|
+
|
575
|
+
def assert_banner_message(self, expected_message):
|
576
|
+
"""
|
577
|
+
Ensure that there is an alert banner on the page with the given message.
|
578
|
+
Currently it only handles a single.
|
579
|
+
"""
|
580
|
+
banner = self.page.wait_for_selector(".alert-banner")
|
581
|
+
banner_text = banner.inner_text()
|
582
|
+
|
583
|
+
if expected_message not in banner_text:
|
584
|
+
raise AssertionError(
|
585
|
+
f"Expected message '{expected_message}' does not "
|
586
|
+
f"match banner text '{banner_text}'"
|
587
|
+
)
|
588
|
+
return True
|
589
|
+
|
590
|
+
def check_tutor_generated_code(self, modules_to_check=None):
|
591
|
+
"""
|
592
|
+
Ensure that the source code viewer injected by the PyTutor plugin
|
593
|
+
is presend. Raise AssertionError if not found.
|
594
|
+
|
595
|
+
Args:
|
596
|
+
|
597
|
+
modules_to_check(str): iterable with names of the python modules
|
598
|
+
that have been included in the tutor config
|
599
|
+
and needs to be checked (if they are included
|
600
|
+
in the displayed source code)
|
601
|
+
|
602
|
+
Returns:
|
603
|
+
None
|
604
|
+
"""
|
605
|
+
# Given: a page that has a <py-tutor> tag
|
606
|
+
assert self.page.locator("py-tutor").count()
|
607
|
+
|
608
|
+
# EXPECT that"
|
609
|
+
#
|
610
|
+
# the page has the "view-code-button"
|
611
|
+
view_code_button = self.page.locator("#view-code-button")
|
612
|
+
vcb_count = view_code_button.count()
|
613
|
+
if vcb_count != 1:
|
614
|
+
raise AssertionError(
|
615
|
+
f"Found {vcb_count} code view button. Should have been 1!"
|
616
|
+
)
|
617
|
+
|
618
|
+
# the page has the code-section element
|
619
|
+
code_section = self.page.locator("#code-section")
|
620
|
+
code_section_count = code_section.count()
|
621
|
+
code_msg = (
|
622
|
+
f"One (and only one) code section should exist. Found: {code_section_count}"
|
623
|
+
)
|
624
|
+
assert code_section_count == 1, code_msg
|
625
|
+
|
626
|
+
pyconfig_tag = self.page.locator("py-config")
|
627
|
+
code_section_inner_html = code_section.inner_html()
|
628
|
+
|
629
|
+
# the code_section has the index.html section
|
630
|
+
assert "<p>index.html</p>" in code_section_inner_html
|
631
|
+
|
632
|
+
# the section has the tags highlighting the HTML code
|
633
|
+
assert (
|
634
|
+
'<pre class="prism-code language-html" tabindex="0">'
|
635
|
+
' <code class="language-html">' in code_section_inner_html
|
636
|
+
)
|
637
|
+
|
638
|
+
# if modules were included, these are also presented in the code section
|
639
|
+
if modules_to_check:
|
640
|
+
for module in modules_to_check:
|
641
|
+
assert f"{module}" in code_section_inner_html
|
642
|
+
|
643
|
+
# the section also includes the config
|
644
|
+
assert "<</span>py-config</span>" in code_section_inner_html
|
645
|
+
|
646
|
+
# the contents of the py-config tag are included in the code section
|
647
|
+
assert pyconfig_tag.inner_html() in code_section_inner_html
|
648
|
+
|
649
|
+
# the code section to be invisible by default (by having the hidden class)
|
650
|
+
assert "code-section-hidden" in code_section.get_attribute("class")
|
651
|
+
|
652
|
+
# once the view_code_button is pressed, the code section becomes visible
|
653
|
+
view_code_button.click()
|
654
|
+
assert "code-section-visible" in code_section.get_attribute("class")
|
655
|
+
|
656
|
+
|
657
|
+
# ============== Helpers and utility functions ==============
|
658
|
+
|
659
|
+
MAX_TEST_TIME = 30 # Number of seconds allowed for checking a testing condition
|
660
|
+
TEST_TIME_INCREMENT = 0.25 # 1/4 second, the length of each iteration
|
661
|
+
TEST_ITERATIONS = math.ceil(
|
662
|
+
MAX_TEST_TIME / TEST_TIME_INCREMENT
|
663
|
+
) # 120 iters of 1/4 second
|
664
|
+
|
665
|
+
|
666
|
+
def wait_for_render(page, selector, pattern, timeout_seconds: int | None = None):
|
667
|
+
"""
|
668
|
+
Assert that rendering inserts data into the page as expected: search the
|
669
|
+
DOM from within the timing loop for a string that is not present in the
|
670
|
+
initial markup but should appear by way of rendering
|
671
|
+
"""
|
672
|
+
re_sub_content = re.compile(pattern)
|
673
|
+
py_rendered = False # Flag to be set to True when condition met
|
674
|
+
|
675
|
+
if timeout_seconds:
|
676
|
+
check_iterations = math.ceil(timeout_seconds / TEST_TIME_INCREMENT)
|
677
|
+
else:
|
678
|
+
check_iterations = TEST_ITERATIONS
|
679
|
+
|
680
|
+
for _ in range(check_iterations):
|
681
|
+
content = page.inner_html(selector)
|
682
|
+
if re_sub_content.search(content):
|
683
|
+
py_rendered = True
|
684
|
+
break
|
685
|
+
time.sleep(TEST_TIME_INCREMENT)
|
686
|
+
|
687
|
+
assert py_rendered # nosec
|
688
|
+
|
689
|
+
|
690
|
+
class PageErrors(Exception):
|
691
|
+
"""
|
692
|
+
Represent one or more exceptions which happened in JS or Python.
|
693
|
+
"""
|
694
|
+
|
695
|
+
def __init__(self, kind, errors):
|
696
|
+
assert kind in ("JS", "Python")
|
697
|
+
n = len(errors)
|
698
|
+
assert n != 0
|
699
|
+
lines = [f"{kind} errors found: {n}"]
|
700
|
+
lines += errors
|
701
|
+
msg = "\n".join(lines)
|
702
|
+
super().__init__(msg)
|
703
|
+
self.errors = errors
|
704
|
+
|
705
|
+
|
706
|
+
class PageErrorsDidNotRaise(Exception):
|
707
|
+
"""
|
708
|
+
Exception raised by check_{js,py}_errors when the expected JS or Python
|
709
|
+
error messages are not found.
|
710
|
+
"""
|
711
|
+
|
712
|
+
def __init__(self, kind, expected_messages, errors):
|
713
|
+
assert kind in ("JS", "Python")
|
714
|
+
lines = [f"The following {kind} errors were expected but could not be found:"]
|
715
|
+
for msg in expected_messages:
|
716
|
+
lines.append(" - " + msg)
|
717
|
+
if errors:
|
718
|
+
lines.append("---")
|
719
|
+
lines.append(f"The following {kind} errors were raised but not expected:")
|
720
|
+
lines += errors
|
721
|
+
msg = "\n".join(lines)
|
722
|
+
super().__init__(msg)
|
723
|
+
self.expected_messages = expected_messages
|
724
|
+
self.errors = errors
|
725
|
+
|
726
|
+
|
727
|
+
class ConsoleMessageCollection:
|
728
|
+
"""
|
729
|
+
Helper class to collect and expose ConsoleMessage in a Pythonic way.
|
730
|
+
|
731
|
+
Usage:
|
732
|
+
|
733
|
+
console.log.messages: list of ConsoleMessage with type=='log'
|
734
|
+
console.log.lines: list of strings
|
735
|
+
console.log.text: the whole text as single string
|
736
|
+
|
737
|
+
console.debug.* same as above, but with different types
|
738
|
+
console.info.*
|
739
|
+
console.error.*
|
740
|
+
console.warning.*
|
741
|
+
|
742
|
+
console.js_error.* this is a special category which does not exist in the
|
743
|
+
browser: it prints uncaught JS exceptions
|
744
|
+
|
745
|
+
console.all.* same as the individual categories but considering
|
746
|
+
all messages which were sent to the console
|
747
|
+
"""
|
748
|
+
|
749
|
+
@dataclass
|
750
|
+
class Message:
|
751
|
+
type: str # 'log', 'info', 'debug', etc.
|
752
|
+
text: str
|
753
|
+
|
754
|
+
class View:
|
755
|
+
"""
|
756
|
+
Filter console messages by the given msg_type
|
757
|
+
"""
|
758
|
+
|
759
|
+
def __init__(self, console, msg_type):
|
760
|
+
self.console = console
|
761
|
+
self.msg_type = msg_type
|
762
|
+
|
763
|
+
@property
|
764
|
+
def messages(self):
|
765
|
+
if self.msg_type is None:
|
766
|
+
return self.console._messages
|
767
|
+
else:
|
768
|
+
return [
|
769
|
+
msg for msg in self.console._messages if msg.type == self.msg_type
|
770
|
+
]
|
771
|
+
|
772
|
+
@property
|
773
|
+
def lines(self):
|
774
|
+
return [msg.text for msg in self.messages]
|
775
|
+
|
776
|
+
@property
|
777
|
+
def text(self):
|
778
|
+
return "\n".join(self.lines)
|
779
|
+
|
780
|
+
_COLORS = {
|
781
|
+
"warning": "brown",
|
782
|
+
"error": "darkred",
|
783
|
+
"js_error": "red",
|
784
|
+
}
|
785
|
+
|
786
|
+
def __init__(self, logger):
|
787
|
+
self.logger = logger
|
788
|
+
self._messages = []
|
789
|
+
self.all = self.View(self, None)
|
790
|
+
self.log = self.View(self, "log")
|
791
|
+
self.debug = self.View(self, "debug")
|
792
|
+
self.info = self.View(self, "info")
|
793
|
+
self.error = self.View(self, "error")
|
794
|
+
self.warning = self.View(self, "warning")
|
795
|
+
self.js_error = self.View(self, "js_error")
|
796
|
+
|
797
|
+
def add_message(self, type, text):
|
798
|
+
# log the message: pytest will capture the output and display the
|
799
|
+
# messages if the test fails.
|
800
|
+
msg = self.Message(type=type, text=text)
|
801
|
+
category = f"console.{msg.type}"
|
802
|
+
color = self._COLORS.get(msg.type)
|
803
|
+
self.logger.log(category, msg.text, color=color)
|
804
|
+
self._messages.append(msg)
|
805
|
+
|
806
|
+
|
807
|
+
class Logger:
|
808
|
+
"""
|
809
|
+
Helper class to log messages to stdout.
|
810
|
+
|
811
|
+
Features:
|
812
|
+
- nice formatted category
|
813
|
+
- keep track of time passed since the last reset
|
814
|
+
- support colors
|
815
|
+
|
816
|
+
NOTE: the (lowercase) logger fixture is defined in conftest.py
|
817
|
+
"""
|
818
|
+
|
819
|
+
def __init__(self):
|
820
|
+
self.reset()
|
821
|
+
# capture things like [pyscript/main]
|
822
|
+
self.prefix_regexp = re.compile(r"(\[.+?\])")
|
823
|
+
|
824
|
+
def reset(self):
|
825
|
+
self.start_time = time.time()
|
826
|
+
|
827
|
+
def colorize_prefix(self, text, *, color):
|
828
|
+
# find the first occurrence of something like [pyscript/main] and
|
829
|
+
# colorize it
|
830
|
+
start, end = Color.escape_pair(color)
|
831
|
+
return self.prefix_regexp.sub(rf"{start}\1{end}", text, 1)
|
832
|
+
|
833
|
+
def log(self, category, text, *, color=None):
|
834
|
+
delta = time.time() - self.start_time
|
835
|
+
text = self.colorize_prefix(text, color="teal")
|
836
|
+
line = f"[{delta:6.2f} {category:17}] {text}"
|
837
|
+
if color:
|
838
|
+
line = Color.set(color, line)
|
839
|
+
print(line)
|
840
|
+
|
841
|
+
|
842
|
+
class Color:
|
843
|
+
"""
|
844
|
+
Helper method to print colored output using ANSI escape codes.
|
845
|
+
"""
|
846
|
+
|
847
|
+
black = "30"
|
848
|
+
darkred = "31"
|
849
|
+
darkgreen = "32"
|
850
|
+
brown = "33"
|
851
|
+
darkblue = "34"
|
852
|
+
purple = "35"
|
853
|
+
teal = "36"
|
854
|
+
lightgray = "37"
|
855
|
+
darkgray = "30;01"
|
856
|
+
red = "31;01"
|
857
|
+
green = "32;01"
|
858
|
+
yellow = "33;01"
|
859
|
+
blue = "34;01"
|
860
|
+
fuchsia = "35;01"
|
861
|
+
turquoise = "36;01"
|
862
|
+
white = "37;01"
|
863
|
+
|
864
|
+
@classmethod
|
865
|
+
def set(cls, color, string):
|
866
|
+
start, end = cls.escape_pair(color)
|
867
|
+
return f"{start}{string}{end}"
|
868
|
+
|
869
|
+
@classmethod
|
870
|
+
def escape_pair(cls, color):
|
871
|
+
try:
|
872
|
+
color = getattr(cls, color)
|
873
|
+
except AttributeError:
|
874
|
+
pass
|
875
|
+
start = f"\x1b[{color}m"
|
876
|
+
end = "\x1b[00m"
|
877
|
+
return start, end
|
878
|
+
|
879
|
+
|
880
|
+
class SmartRouter:
|
881
|
+
"""
|
882
|
+
A smart router to be used in conjunction with playwright.Page.route.
|
883
|
+
|
884
|
+
Main features:
|
885
|
+
|
886
|
+
- it intercepts the requests to a local "fake server" and serve them
|
887
|
+
statically from disk
|
888
|
+
|
889
|
+
- it intercepts the requests to the network and cache the results
|
890
|
+
locally
|
891
|
+
"""
|
892
|
+
|
893
|
+
@dataclass
|
894
|
+
class CachedResponse:
|
895
|
+
"""
|
896
|
+
We cannot put playwright's APIResponse instances inside _cache, because
|
897
|
+
they are valid only in the context of the same page. As a workaround,
|
898
|
+
we manually save status, headers and body of each cached response.
|
899
|
+
"""
|
900
|
+
|
901
|
+
status: int
|
902
|
+
headers: dict
|
903
|
+
body: str
|
904
|
+
|
905
|
+
def asdict(self):
|
906
|
+
return dataclasses.asdict(self)
|
907
|
+
|
908
|
+
@classmethod
|
909
|
+
def fromdict(cls, d):
|
910
|
+
return cls(**d)
|
911
|
+
|
912
|
+
def __init__(self, fake_server, *, cache, logger, usepdb=False):
|
913
|
+
"""
|
914
|
+
fake_server: the domain name of the fake server
|
915
|
+
"""
|
916
|
+
self.fake_server = fake_server
|
917
|
+
self.cache = cache # this is pytest-cache, it survives across sessions
|
918
|
+
self.logger = logger
|
919
|
+
self.usepdb = usepdb
|
920
|
+
self.page = None
|
921
|
+
self.requests = [] # (status, kind, url)
|
922
|
+
self.enable_cors_headers = True
|
923
|
+
|
924
|
+
@property
|
925
|
+
def headers(self):
|
926
|
+
if self.enable_cors_headers:
|
927
|
+
return {
|
928
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
929
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
930
|
+
}
|
931
|
+
return {}
|
932
|
+
|
933
|
+
def install(self, page):
|
934
|
+
"""
|
935
|
+
Install the smart router on a page
|
936
|
+
"""
|
937
|
+
self.page = page
|
938
|
+
self.page.route("**", self.router)
|
939
|
+
|
940
|
+
def router(self, route):
|
941
|
+
"""
|
942
|
+
Intercept and fulfill playwright requests.
|
943
|
+
|
944
|
+
NOTE!
|
945
|
+
If we raise an exception inside router, playwright just hangs and the
|
946
|
+
exception seems not to be propagated outside. It's very likely a
|
947
|
+
playwright bug.
|
948
|
+
|
949
|
+
This means that for example pytest doesn't have any chance to
|
950
|
+
intercept the exception and fail in a meaningful way.
|
951
|
+
|
952
|
+
As a workaround, we try to intercept exceptions by ourselves, print
|
953
|
+
something reasonable on the console and abort the request (hoping that
|
954
|
+
the test will fail cleaninly, that's the best we can do). We also try
|
955
|
+
to respect pytest --pdb, for what it's possible.
|
956
|
+
"""
|
957
|
+
try:
|
958
|
+
return self._router(route)
|
959
|
+
except Exception:
|
960
|
+
print("***** Error inside Fake_Server.router *****")
|
961
|
+
info = sys.exc_info()
|
962
|
+
print(traceback.format_exc())
|
963
|
+
if self.usepdb:
|
964
|
+
pdb.post_mortem(info[2])
|
965
|
+
route.abort()
|
966
|
+
|
967
|
+
def log_request(self, status, kind, url):
|
968
|
+
self.requests.append((status, kind, url))
|
969
|
+
color = "blue" if status == 200 else "red"
|
970
|
+
self.logger.log("request", f"{status} - {kind} - {url}", color=color)
|
971
|
+
|
972
|
+
def _router(self, route):
|
973
|
+
full_url = route.request.url
|
974
|
+
url = urllib.parse.urlparse(full_url)
|
975
|
+
assert url.scheme in ("http", "https")
|
976
|
+
|
977
|
+
# requests to http://fake_server/ are served from the current dir and
|
978
|
+
# never cached
|
979
|
+
if url.netloc == self.fake_server:
|
980
|
+
self.log_request(200, "fake_server", full_url)
|
981
|
+
assert url.path[0] == "/"
|
982
|
+
relative_path = url.path[1:]
|
983
|
+
if os.path.exists(relative_path):
|
984
|
+
route.fulfill(status=200, headers=self.headers, path=relative_path)
|
985
|
+
else:
|
986
|
+
route.fulfill(status=404, headers=self.headers)
|
987
|
+
return
|
988
|
+
|
989
|
+
# network requests might be cached
|
990
|
+
resp = self.fetch_from_cache(full_url)
|
991
|
+
if resp is not None:
|
992
|
+
kind = "CACHED"
|
993
|
+
else:
|
994
|
+
kind = "NETWORK"
|
995
|
+
resp = self.fetch_from_network(route.request)
|
996
|
+
self.save_resp_to_cache(full_url, resp)
|
997
|
+
|
998
|
+
self.log_request(resp.status, kind, full_url)
|
999
|
+
route.fulfill(status=resp.status, headers=resp.headers, body=resp.body)
|
1000
|
+
|
1001
|
+
def clear_cache(self, url):
|
1002
|
+
key = "pyscript/" + url
|
1003
|
+
self.cache.set(key, None)
|
1004
|
+
|
1005
|
+
def save_resp_to_cache(self, url, resp):
|
1006
|
+
key = "pyscript/" + url
|
1007
|
+
data = resp.asdict()
|
1008
|
+
# cache.set encodes it as JSON, and "bytes" are not supported: let's
|
1009
|
+
# encode them as latin-1
|
1010
|
+
data["body"] = data["body"].decode("latin-1")
|
1011
|
+
self.cache.set(key, data)
|
1012
|
+
|
1013
|
+
def fetch_from_cache(self, url):
|
1014
|
+
key = "pyscript/" + url
|
1015
|
+
data = self.cache.get(key, None)
|
1016
|
+
if data is None:
|
1017
|
+
return None
|
1018
|
+
# see the corresponding comment in save_resp_to_cache
|
1019
|
+
data["body"] = data["body"].encode("latin-1")
|
1020
|
+
return self.CachedResponse(**data)
|
1021
|
+
|
1022
|
+
def fetch_from_network(self, request):
|
1023
|
+
# sometimes the network is flaky and if the first request doesn't
|
1024
|
+
# work, a subsequent one works. Instead of giving up immediately,
|
1025
|
+
# let's try twice
|
1026
|
+
try:
|
1027
|
+
api_response = self.page.request.fetch(request)
|
1028
|
+
except PlaywrightError:
|
1029
|
+
# sleep a bit and try again
|
1030
|
+
time.sleep(0.5)
|
1031
|
+
api_response = self.page.request.fetch(request)
|
1032
|
+
|
1033
|
+
cached_response = self.CachedResponse(
|
1034
|
+
status=api_response.status,
|
1035
|
+
headers=api_response.headers,
|
1036
|
+
body=api_response.body(),
|
1037
|
+
)
|
1038
|
+
return cached_response
|