@pyscript/core 0.1.22 → 0.2.1

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 (41) hide show
  1. package/dist/core.css +1 -1
  2. package/dist/core.js +2 -2
  3. package/dist/core.js.map +1 -1
  4. package/dist/error-e4fe78fd.js +2 -0
  5. package/dist/error-e4fe78fd.js.map +1 -0
  6. package/package.json +2 -2
  7. package/src/core.css +3 -1
  8. package/src/core.js +170 -161
  9. package/src/plugins/error.js +2 -2
  10. package/src/stdlib/pyscript/__init__.py +10 -1
  11. package/src/stdlib/pyscript/display.py +7 -0
  12. package/src/stdlib/pyscript/util.py +3 -4
  13. package/src/stdlib/pyscript.js +3 -3
  14. package/dist/error-87e0706c.js +0 -2
  15. package/dist/error-87e0706c.js.map +0 -1
  16. package/tests/integration/__init__.py +0 -0
  17. package/tests/integration/conftest.py +0 -184
  18. package/tests/integration/support.py +0 -1038
  19. package/tests/integration/test_00_support.py +0 -495
  20. package/tests/integration/test_01_basic.py +0 -353
  21. package/tests/integration/test_02_display.py +0 -452
  22. package/tests/integration/test_03_element.py +0 -303
  23. package/tests/integration/test_assets/line_plot.png +0 -0
  24. package/tests/integration/test_assets/tripcolor.png +0 -0
  25. package/tests/integration/test_async.py +0 -197
  26. package/tests/integration/test_event_handling.py +0 -193
  27. package/tests/integration/test_importmap.py +0 -66
  28. package/tests/integration/test_interpreter.py +0 -98
  29. package/tests/integration/test_plugins.py +0 -419
  30. package/tests/integration/test_py_config.py +0 -294
  31. package/tests/integration/test_py_repl.py +0 -663
  32. package/tests/integration/test_py_terminal.py +0 -270
  33. package/tests/integration/test_runtime_attributes.py +0 -64
  34. package/tests/integration/test_script_type.py +0 -121
  35. package/tests/integration/test_shadow_root.py +0 -33
  36. package/tests/integration/test_splashscreen.py +0 -124
  37. package/tests/integration/test_stdio_handling.py +0 -370
  38. package/tests/integration/test_style.py +0 -47
  39. package/tests/integration/test_warnings_and_banners.py +0 -32
  40. package/tests/integration/test_zz_examples.py +0 -419
  41. package/tests/integration/test_zzz_docs_snippets.py +0 -305
@@ -1,1038 +0,0 @@
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 "&lt;</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