playwright-limelight 0.2.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.
- limelight/__init__.py +47 -0
- limelight/application.py +102 -0
- limelight/artifacts.py +13 -0
- limelight/barriers.py +364 -0
- limelight/capture/__init__.py +1 -0
- limelight/capture/browser.py +81 -0
- limelight/capture/camera.py +74 -0
- limelight/capture/renderer.py +687 -0
- limelight/capture/sinks.py +205 -0
- limelight/components/__init__.py +16 -0
- limelight/components/confirm.py +84 -0
- limelight/components/dropdown.py +147 -0
- limelight/components/modal.py +348 -0
- limelight/components/navigator.py +129 -0
- limelight/components/search_select.py +197 -0
- limelight/config.py +284 -0
- limelight/demo.py +644 -0
- limelight/django/__init__.py +295 -0
- limelight/django/pytest_plugin.py +131 -0
- limelight/django/server.py +72 -0
- limelight/export/__init__.py +80 -0
- limelight/export/chapters.py +56 -0
- limelight/export/cli.py +94 -0
- limelight/export/subtitles.py +116 -0
- limelight/export/video.py +133 -0
- limelight/export/voiceover.py +130 -0
- limelight/export/walkthrough.py +305 -0
- limelight/ffmpeg.py +323 -0
- limelight/gestures.py +88 -0
- limelight/javascript.py +17 -0
- limelight/ledger.py +249 -0
- limelight/narrator.py +418 -0
- limelight/overlay/__init__.py +571 -0
- limelight/overlay/assets/animation.js +50 -0
- limelight/overlay/assets/api.js +301 -0
- limelight/overlay/assets/control.js +220 -0
- limelight/overlay/assets/cursor.js +77 -0
- limelight/overlay/assets/dom.js +37 -0
- limelight/overlay/assets/install.js +38 -0
- limelight/overlay/assets/keys.js +25 -0
- limelight/overlay/assets/overlay.css +331 -0
- limelight/overlay/assets/spot.js +47 -0
- limelight/overlay/assets.py +50 -0
- limelight/overlay/bridge.py +182 -0
- limelight/overlay/cursor.py +234 -0
- limelight/overlay/keyboard.py +138 -0
- limelight/overlay/playback.py +304 -0
- limelight/py.typed +0 -0
- limelight/pytest_plugin.py +485 -0
- limelight/scene.py +212 -0
- limelight/scripts/input_type.js +1 -0
- limelight/scripts/locator_label.js +14 -0
- limelight/scripts/point_hit.js +15 -0
- limelight/scripts/select_option_labels.js +1 -0
- limelight/theme.py +54 -0
- limelight/transcript.py +174 -0
- limelight/world.py +82 -0
- playwright_limelight-0.2.0.dist-info/METADATA +220 -0
- playwright_limelight-0.2.0.dist-info/RECORD +62 -0
- playwright_limelight-0.2.0.dist-info/WHEEL +4 -0
- playwright_limelight-0.2.0.dist-info/entry_points.txt +3 -0
- playwright_limelight-0.2.0.dist-info/licenses/LICENSE +21 -0
limelight/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from limelight.application import Application, StaticApplication
|
|
4
|
+
from limelight.barriers import (
|
|
5
|
+
requests_settled,
|
|
6
|
+
trigger_until_navigation,
|
|
7
|
+
trigger_until_response,
|
|
8
|
+
trigger_until_visible,
|
|
9
|
+
)
|
|
10
|
+
from limelight.components import (
|
|
11
|
+
ELEMENT_WAIT_TIMEOUT_MS,
|
|
12
|
+
Confirm,
|
|
13
|
+
Dropdown,
|
|
14
|
+
Modal,
|
|
15
|
+
Navigator,
|
|
16
|
+
SearchAndSelect,
|
|
17
|
+
)
|
|
18
|
+
from limelight.config import DemoConfig
|
|
19
|
+
from limelight.demo import Demo
|
|
20
|
+
from limelight.ledger import Direction, Ledger, LedgerRow, Sentiment
|
|
21
|
+
from limelight.scene import Scene
|
|
22
|
+
from limelight.theme import Theme
|
|
23
|
+
from limelight.world import World
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'ELEMENT_WAIT_TIMEOUT_MS',
|
|
27
|
+
'Application',
|
|
28
|
+
'Confirm',
|
|
29
|
+
'Demo',
|
|
30
|
+
'DemoConfig',
|
|
31
|
+
'Direction',
|
|
32
|
+
'Dropdown',
|
|
33
|
+
'Ledger',
|
|
34
|
+
'LedgerRow',
|
|
35
|
+
'Modal',
|
|
36
|
+
'Navigator',
|
|
37
|
+
'Scene',
|
|
38
|
+
'SearchAndSelect',
|
|
39
|
+
'Sentiment',
|
|
40
|
+
'StaticApplication',
|
|
41
|
+
'Theme',
|
|
42
|
+
'World',
|
|
43
|
+
'requests_settled',
|
|
44
|
+
'trigger_until_navigation',
|
|
45
|
+
'trigger_until_response',
|
|
46
|
+
'trigger_until_visible',
|
|
47
|
+
]
|
limelight/application.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from playwright.sync_api import Page
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Application(Protocol):
|
|
12
|
+
"""
|
|
13
|
+
A protocol for the application a demo records.
|
|
14
|
+
|
|
15
|
+
This protocol covers the two things a demo needs from the project under
|
|
16
|
+
test: a way to sign a user in, and a way to turn a route into a URL.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def login(self, page: Page, user: object) -> None:
|
|
20
|
+
"""
|
|
21
|
+
A method that signs a user into the application.
|
|
22
|
+
|
|
23
|
+
:param page: The page the demo drives.
|
|
24
|
+
:param user: The user to sign in as.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def url(self, route: str, **url_kwargs: object) -> str:
|
|
30
|
+
"""
|
|
31
|
+
A method that resolves a route into an absolute URL.
|
|
32
|
+
|
|
33
|
+
:param route: The route to resolve.
|
|
34
|
+
:param url_kwargs: The arguments filled into the route.
|
|
35
|
+
:return: The absolute URL to navigate to.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StaticApplication:
|
|
42
|
+
"""
|
|
43
|
+
An application backed by a fixed base URL.
|
|
44
|
+
|
|
45
|
+
This class serves any site that is already running, so a demo can record
|
|
46
|
+
against a deployed environment without a Django project behind it.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
base_url: str,
|
|
53
|
+
login: Callable[[Page, object], None] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
The constructor for the StaticApplication class.
|
|
57
|
+
|
|
58
|
+
:param base_url: The origin every route is resolved against.
|
|
59
|
+
:param login: The callable that signs a user in, or None for a site that needs no login.
|
|
60
|
+
:raises ValueError: If the base URL is empty.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
base_url_clean = base_url.strip().rstrip('/')
|
|
64
|
+
|
|
65
|
+
if not base_url_clean:
|
|
66
|
+
message = f'base_url must not be empty (got "{base_url}")'
|
|
67
|
+
raise ValueError(message)
|
|
68
|
+
|
|
69
|
+
self.base_url = base_url_clean
|
|
70
|
+
self._login = login
|
|
71
|
+
|
|
72
|
+
def login(self, page: Page, user: object) -> None:
|
|
73
|
+
"""
|
|
74
|
+
A method that signs a user in through the configured callable.
|
|
75
|
+
|
|
76
|
+
:param page: The page the demo drives.
|
|
77
|
+
:param user: The user to sign in as.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
if self._login is not None:
|
|
81
|
+
self._login(page, user)
|
|
82
|
+
|
|
83
|
+
def url(self, route: str, **url_kwargs: object) -> str:
|
|
84
|
+
"""
|
|
85
|
+
A method that joins a route onto the base URL.
|
|
86
|
+
|
|
87
|
+
:param route: The route to resolve, with or without a leading slash.
|
|
88
|
+
:param url_kwargs: The arguments filled into the route.
|
|
89
|
+
:return: The absolute URL to navigate to.
|
|
90
|
+
:raises ValueError: If the route is empty.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
if not route.strip():
|
|
94
|
+
message = f'route must not be empty (got "{route}")'
|
|
95
|
+
raise ValueError(message)
|
|
96
|
+
|
|
97
|
+
path = route.format(**url_kwargs)
|
|
98
|
+
|
|
99
|
+
if not path.startswith('/'):
|
|
100
|
+
path = f'/{path}'
|
|
101
|
+
|
|
102
|
+
return f'{self.base_url}{path}'
|
limelight/artifacts.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
DIRECTORY_ROOT = '.demos'
|
|
5
|
+
|
|
6
|
+
CHAPTERS_FILE_NAME = 'chapters.txt'
|
|
7
|
+
GIF_FILE_NAME = 'video.gif'
|
|
8
|
+
RENDER_FILE_NAME = 'render.mp4'
|
|
9
|
+
SUBTITLES_FILE_NAME = 'subtitles.vtt'
|
|
10
|
+
TRANSCRIPT_FILE_NAME = 'transcript.json'
|
|
11
|
+
VIDEO_FILE_NAME = 'video.mp4'
|
|
12
|
+
VOICEOVER_FILE_NAME = 'voiceover.wav'
|
|
13
|
+
WALKTHROUGH_FILE_NAME = 'walkthrough.md'
|
limelight/barriers.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, expect
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from playwright.sync_api import Locator, Page, Request, Response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ATTEMPT_COUNT_DEFAULT = 3
|
|
18
|
+
BARRIER_TIMEOUT_MS_DEFAULT = 5000
|
|
19
|
+
SETTLE_INTERVAL_MS_DEFAULT = 100
|
|
20
|
+
SETTLE_TIMEOUT_MS_DEFAULT = 15_000
|
|
21
|
+
WAIT_ATTEMPT_COUNT_MAX = 40
|
|
22
|
+
WAIT_INTERVAL_MS_DEFAULT = 250
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _RequestTally:
|
|
26
|
+
"""
|
|
27
|
+
A running count of the requests a URL fragment names.
|
|
28
|
+
|
|
29
|
+
The count rises when such a request starts and falls when it finishes or
|
|
30
|
+
fails, so a block that fires several requests is settled only once the last
|
|
31
|
+
of them is answered.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, url_fragment: str) -> None:
|
|
35
|
+
"""
|
|
36
|
+
The constructor for the _RequestTally class.
|
|
37
|
+
|
|
38
|
+
:param url_fragment: The text a tallied request URL contains.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
self.count = 0
|
|
42
|
+
self.url_fragment = url_fragment
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_quiet(self) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
A property that reports whether every tallied request has been answered.
|
|
48
|
+
|
|
49
|
+
:return: True if nothing is in flight, False otherwise.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
return self.count <= 0
|
|
53
|
+
|
|
54
|
+
def settled(self, request: Request) -> None:
|
|
55
|
+
"""
|
|
56
|
+
A method that drops a request from the tally.
|
|
57
|
+
|
|
58
|
+
:param request: The request that finished or failed.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
if self.url_fragment in request.url:
|
|
62
|
+
self.count -= 1
|
|
63
|
+
|
|
64
|
+
def started(self, request: Request) -> None:
|
|
65
|
+
"""
|
|
66
|
+
A method that adds a request to the tally.
|
|
67
|
+
|
|
68
|
+
:param request: The request that started.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
if self.url_fragment in request.url:
|
|
72
|
+
self.count += 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _attempt_count_validate(attempt_count: int) -> None:
|
|
76
|
+
"""
|
|
77
|
+
A function that rejects an attempt count below one.
|
|
78
|
+
|
|
79
|
+
:param attempt_count: The number of times a barrier retries its trigger.
|
|
80
|
+
:raises ValueError: If the attempt count is not positive.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if attempt_count < 1:
|
|
84
|
+
message = f'attempt_count must be positive: {attempt_count}'
|
|
85
|
+
raise ValueError(message)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _method_predicate(method: str) -> Callable[[Response], bool]:
|
|
89
|
+
"""
|
|
90
|
+
A function that builds a response predicate from an HTTP method.
|
|
91
|
+
|
|
92
|
+
:param method: The method the request behind the response was made with.
|
|
93
|
+
:return: The predicate that matches such a response.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
wanted = method.upper()
|
|
97
|
+
|
|
98
|
+
def matches(response: Response) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
A function that reports whether a response answers that method.
|
|
101
|
+
|
|
102
|
+
:param response: The response to test.
|
|
103
|
+
:return: True if the request used the method, False otherwise.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
return response.request.method.upper() == wanted
|
|
107
|
+
|
|
108
|
+
return matches
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _url_fragment_predicate(url_fragment: str) -> Callable[[Response], bool]:
|
|
112
|
+
"""
|
|
113
|
+
A function that builds a response predicate from a substring of a URL.
|
|
114
|
+
|
|
115
|
+
:param url_fragment: The text the response URL must contain.
|
|
116
|
+
:return: The predicate that matches such a response.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def matches(response: Response) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
A function that reports whether a response URL carries the fragment.
|
|
122
|
+
|
|
123
|
+
:param response: The response to test.
|
|
124
|
+
:return: True if the URL contains the fragment, False otherwise.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
return url_fragment in response.url
|
|
128
|
+
|
|
129
|
+
return matches
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@contextmanager
|
|
133
|
+
def requests_settled(
|
|
134
|
+
page: Page,
|
|
135
|
+
*,
|
|
136
|
+
url_fragment: str,
|
|
137
|
+
timeout_ms: int = SETTLE_TIMEOUT_MS_DEFAULT,
|
|
138
|
+
interval_ms: int = SETTLE_INTERVAL_MS_DEFAULT,
|
|
139
|
+
) -> Iterator[None]:
|
|
140
|
+
"""
|
|
141
|
+
A context manager that holds the block open until its background requests finish.
|
|
142
|
+
|
|
143
|
+
The requests a fragment names are tallied while the block runs, and the exit
|
|
144
|
+
waits for the tally to fall back to zero. A page that answers a click with a
|
|
145
|
+
request rather than a navigation settles on the request itself this way,
|
|
146
|
+
rather than on a fixed sleep or on network silence the page never reaches.
|
|
147
|
+
|
|
148
|
+
:param page: The page whose requests are tallied.
|
|
149
|
+
:param url_fragment: The text a tallied request URL contains.
|
|
150
|
+
:param timeout_ms: The time the exit waits for the tally to clear.
|
|
151
|
+
:param interval_ms: The time waited between reads of the tally.
|
|
152
|
+
:raises ValueError: If the fragment is empty, or a duration is not positive.
|
|
153
|
+
:raises AssertionError: If the requests never finish.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
if not url_fragment.strip():
|
|
157
|
+
message = f'url_fragment must not be empty (got "{url_fragment}")'
|
|
158
|
+
raise ValueError(message)
|
|
159
|
+
|
|
160
|
+
if timeout_ms < 1:
|
|
161
|
+
message = f'timeout_ms must be positive: {timeout_ms}'
|
|
162
|
+
raise ValueError(message)
|
|
163
|
+
|
|
164
|
+
if interval_ms < 1:
|
|
165
|
+
message = f'interval_ms must be positive: {interval_ms}'
|
|
166
|
+
raise ValueError(message)
|
|
167
|
+
|
|
168
|
+
pending = _RequestTally(url_fragment)
|
|
169
|
+
|
|
170
|
+
page.on('request', pending.started)
|
|
171
|
+
page.on('requestfinished', pending.settled)
|
|
172
|
+
page.on('requestfailed', pending.settled)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
yield
|
|
176
|
+
|
|
177
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
178
|
+
|
|
179
|
+
while not pending.is_quiet:
|
|
180
|
+
if time.monotonic() >= deadline:
|
|
181
|
+
message = (
|
|
182
|
+
f'the requests to "{url_fragment}" never settled '
|
|
183
|
+
f'within {timeout_ms}ms'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
raise AssertionError(message)
|
|
187
|
+
|
|
188
|
+
page.wait_for_timeout(interval_ms)
|
|
189
|
+
finally:
|
|
190
|
+
page.remove_listener('request', pending.started)
|
|
191
|
+
page.remove_listener('requestfinished', pending.settled)
|
|
192
|
+
page.remove_listener('requestfailed', pending.settled)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def trigger_until_navigation(
|
|
196
|
+
page: Page,
|
|
197
|
+
trigger: Callable[[], None],
|
|
198
|
+
*,
|
|
199
|
+
url_pattern: str | re.Pattern[str] | None = None,
|
|
200
|
+
attempt_count: int = ATTEMPT_COUNT_DEFAULT,
|
|
201
|
+
timeout_ms: int = BARRIER_TIMEOUT_MS_DEFAULT,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""
|
|
204
|
+
A function that repeats a trigger until the page navigates.
|
|
205
|
+
|
|
206
|
+
The trigger is retried because a click can land before the handler that
|
|
207
|
+
listens for it is bound, and a lost click leaves the page where it was with
|
|
208
|
+
no error to catch. The final attempt re-raises, so a page that never
|
|
209
|
+
navigates fails rather than passing silently.
|
|
210
|
+
|
|
211
|
+
:param page: The page expected to navigate.
|
|
212
|
+
:param trigger: The action that causes the navigation.
|
|
213
|
+
:param url_pattern: The URL the navigation must match, or None for any.
|
|
214
|
+
:param attempt_count: The number of times the trigger is retried.
|
|
215
|
+
:param timeout_ms: The time each attempt waits for the navigation.
|
|
216
|
+
:raises ValueError: If the attempt count is not positive.
|
|
217
|
+
:raises PlaywrightTimeoutError: If the last attempt sees no navigation.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
_attempt_count_validate(attempt_count)
|
|
221
|
+
|
|
222
|
+
for attempt in range(attempt_count):
|
|
223
|
+
try:
|
|
224
|
+
with page.expect_navigation(url=url_pattern, timeout=timeout_ms):
|
|
225
|
+
trigger()
|
|
226
|
+
except PlaywrightTimeoutError:
|
|
227
|
+
if attempt == attempt_count - 1:
|
|
228
|
+
raise
|
|
229
|
+
else:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def trigger_until_response(
|
|
234
|
+
page: Page,
|
|
235
|
+
trigger: Callable[[], None],
|
|
236
|
+
*,
|
|
237
|
+
url_fragment: str = '',
|
|
238
|
+
method: str = '',
|
|
239
|
+
predicate: Callable[[Response], bool] | None = None,
|
|
240
|
+
attempt_count: int = ATTEMPT_COUNT_DEFAULT,
|
|
241
|
+
timeout_ms: int = BARRIER_TIMEOUT_MS_DEFAULT,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""
|
|
244
|
+
A function that repeats a trigger until a matching response arrives.
|
|
245
|
+
|
|
246
|
+
The response is matched by a substring of its URL, by the method its request
|
|
247
|
+
was made with, or by a predicate, exactly one at a time, so no two matchers
|
|
248
|
+
can disagree about which response ends the wait.
|
|
249
|
+
|
|
250
|
+
:param page: The page expected to receive the response.
|
|
251
|
+
:param trigger: The action that causes the request.
|
|
252
|
+
:param url_fragment: The text the response URL must contain.
|
|
253
|
+
:param method: The HTTP method the request behind the response was made with.
|
|
254
|
+
:param predicate: The test a response must pass.
|
|
255
|
+
:param attempt_count: The number of times the trigger is retried.
|
|
256
|
+
:param timeout_ms: The time each attempt waits for the response.
|
|
257
|
+
:raises ValueError: If the attempt count is not positive, or if the matchers are
|
|
258
|
+
not given exactly one at a time.
|
|
259
|
+
:raises PlaywrightTimeoutError: If the last attempt sees no matching response.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
_attempt_count_validate(attempt_count)
|
|
263
|
+
|
|
264
|
+
matcher_count = sum((bool(url_fragment), bool(method), predicate is not None))
|
|
265
|
+
|
|
266
|
+
if matcher_count != 1:
|
|
267
|
+
message = (
|
|
268
|
+
'trigger_until_response takes exactly one of url_fragment, method and predicate'
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
raise ValueError(message)
|
|
272
|
+
|
|
273
|
+
if predicate is not None:
|
|
274
|
+
matches = predicate
|
|
275
|
+
elif method:
|
|
276
|
+
matches = _method_predicate(method)
|
|
277
|
+
else:
|
|
278
|
+
matches = _url_fragment_predicate(url_fragment)
|
|
279
|
+
|
|
280
|
+
for attempt in range(attempt_count):
|
|
281
|
+
try:
|
|
282
|
+
with page.expect_response(matches, timeout=timeout_ms):
|
|
283
|
+
trigger()
|
|
284
|
+
except PlaywrightTimeoutError:
|
|
285
|
+
if attempt == attempt_count - 1:
|
|
286
|
+
raise
|
|
287
|
+
else:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def trigger_until_visible(
|
|
292
|
+
trigger: Callable[[], None],
|
|
293
|
+
locator: Locator,
|
|
294
|
+
*,
|
|
295
|
+
attempt_count: int = ATTEMPT_COUNT_DEFAULT,
|
|
296
|
+
timeout_ms: int = BARRIER_TIMEOUT_MS_DEFAULT,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""
|
|
299
|
+
A function that repeats a trigger until an element becomes visible.
|
|
300
|
+
|
|
301
|
+
:param trigger: The action that reveals the element.
|
|
302
|
+
:param locator: The locator for the element that must appear.
|
|
303
|
+
:param attempt_count: The number of times the trigger is retried.
|
|
304
|
+
:param timeout_ms: The time each attempt waits for the element.
|
|
305
|
+
:raises ValueError: If the attempt count is not positive.
|
|
306
|
+
:raises AssertionError: If the element is still hidden after the last attempt.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
_attempt_count_validate(attempt_count)
|
|
310
|
+
|
|
311
|
+
for attempt in range(attempt_count):
|
|
312
|
+
trigger()
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
expect(locator).to_be_visible(timeout=timeout_ms)
|
|
316
|
+
except AssertionError:
|
|
317
|
+
if attempt == attempt_count - 1:
|
|
318
|
+
raise
|
|
319
|
+
else:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def wait_until(
|
|
324
|
+
predicate: Callable[[], bool],
|
|
325
|
+
hold: Callable[[int], None],
|
|
326
|
+
*,
|
|
327
|
+
attempt_count_max: int = WAIT_ATTEMPT_COUNT_MAX,
|
|
328
|
+
description: str = '',
|
|
329
|
+
interval_ms: int = WAIT_INTERVAL_MS_DEFAULT,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""
|
|
332
|
+
A function that polls a condition until it holds or the attempts run out.
|
|
333
|
+
|
|
334
|
+
The wait is handed a hold callable rather than sleeping itself, so a demo
|
|
335
|
+
that drives the clock can advance its own timeline between polls instead of
|
|
336
|
+
burning real seconds.
|
|
337
|
+
|
|
338
|
+
:param predicate: The condition polled between holds.
|
|
339
|
+
:param hold: The callable that waits for the given number of milliseconds.
|
|
340
|
+
:param attempt_count_max: The number of times the condition is polled.
|
|
341
|
+
:param description: What the condition is waiting for, named in the failure.
|
|
342
|
+
:param interval_ms: The time held between polls.
|
|
343
|
+
:raises ValueError: If the attempt count or the interval is not positive.
|
|
344
|
+
:raises AssertionError: If the condition never holds.
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
if attempt_count_max < 1:
|
|
348
|
+
message = f'attempt_count_max must be positive: {attempt_count_max}'
|
|
349
|
+
raise ValueError(message)
|
|
350
|
+
|
|
351
|
+
if interval_ms < 1:
|
|
352
|
+
message = f'interval_ms must be positive: {interval_ms}'
|
|
353
|
+
raise ValueError(message)
|
|
354
|
+
|
|
355
|
+
for _ in range(attempt_count_max):
|
|
356
|
+
if predicate():
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
hold(interval_ms)
|
|
360
|
+
|
|
361
|
+
subject = description or 'condition'
|
|
362
|
+
|
|
363
|
+
message = f'{subject} did not hold after {attempt_count_max * interval_ms}ms'
|
|
364
|
+
raise AssertionError(message)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ENDPOINT_HOST = '127.0.0.1'
|
|
9
|
+
|
|
10
|
+
LAUNCH_ARGUMENTS_FRAME_CONTROL = (
|
|
11
|
+
'--disable-checker-imaging',
|
|
12
|
+
'--disable-image-animation-resync',
|
|
13
|
+
'--disable-threaded-animation',
|
|
14
|
+
'--disable-threaded-scrolling',
|
|
15
|
+
'--enable-begin-frame-control',
|
|
16
|
+
'--hide-scrollbars',
|
|
17
|
+
'--run-all-compositor-stages-before-draw',
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def endpoint_free() -> str:
|
|
22
|
+
"""
|
|
23
|
+
A function that reserves a free local port for the debugging endpoint.
|
|
24
|
+
|
|
25
|
+
The socket is bound and closed rather than held, so the port is only known
|
|
26
|
+
to be free at the moment it is picked: Chrome has to be launched onto it
|
|
27
|
+
before something else takes it.
|
|
28
|
+
|
|
29
|
+
:return: The URL of the debugging endpoint.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
with socket.socket() as listener:
|
|
33
|
+
listener.bind((ENDPOINT_HOST, 0))
|
|
34
|
+
|
|
35
|
+
port = listener.getsockname()[1]
|
|
36
|
+
|
|
37
|
+
return f'http://{ENDPOINT_HOST}:{port}'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def endpoint_port(endpoint: str) -> int:
|
|
41
|
+
"""
|
|
42
|
+
A function that reads the port out of a debugging endpoint URL.
|
|
43
|
+
|
|
44
|
+
:param endpoint: The URL of the debugging endpoint.
|
|
45
|
+
:return: The port the endpoint listens on.
|
|
46
|
+
:raises ValueError: If the endpoint carries no port.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
port = urlsplit(endpoint).port
|
|
50
|
+
|
|
51
|
+
if port is None:
|
|
52
|
+
message = f'the endpoint carries no port: {endpoint}'
|
|
53
|
+
raise ValueError(message)
|
|
54
|
+
|
|
55
|
+
return port
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def launch_arguments_frame_control(endpoint: str, *, device_scale_factor: int = 1) -> list[str]:
|
|
59
|
+
"""
|
|
60
|
+
A function that builds the Chrome arguments for frame-by-frame capture.
|
|
61
|
+
|
|
62
|
+
The switches hand the compositor to the driver: animation and scrolling are
|
|
63
|
+
pulled off their own threads, every compositor stage runs before a draw, and
|
|
64
|
+
begin-frame control lets the renderer produce one frame per request rather
|
|
65
|
+
than on a wall clock, so a slow screenshot cannot skew the timeline.
|
|
66
|
+
|
|
67
|
+
:param endpoint: The URL of the debugging endpoint Chrome listens on.
|
|
68
|
+
:param device_scale_factor: The pixel ratio the page is rendered at.
|
|
69
|
+
:return: The command-line arguments to launch Chrome with.
|
|
70
|
+
:raises ValueError: If the device scale factor is not positive.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
if device_scale_factor < 1:
|
|
74
|
+
message = f'device_scale_factor must be positive: {device_scale_factor}'
|
|
75
|
+
raise ValueError(message)
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
*LAUNCH_ARGUMENTS_FRAME_CONTROL,
|
|
79
|
+
f'--force-device-scale-factor={device_scale_factor}',
|
|
80
|
+
f'--remote-debugging-port={endpoint_port(endpoint)}',
|
|
81
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from playwright.sync_api import Page
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCREENSHOT_NAME_INVALID_PATTERN = re.compile(r'[^A-Za-z0-9._-]+')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Camera:
|
|
17
|
+
"""
|
|
18
|
+
A screenshot recorder for a demo run.
|
|
19
|
+
|
|
20
|
+
This class numbers each screenshot as it is taken, so the files sort in the
|
|
21
|
+
order the demo produced them rather than by name.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, page: Page, directory: Path) -> None:
|
|
25
|
+
"""
|
|
26
|
+
The constructor for the Camera class.
|
|
27
|
+
|
|
28
|
+
:param page: The page the screenshots are taken from.
|
|
29
|
+
:param directory: The directory the screenshots are written to.
|
|
30
|
+
:raises ValueError: If the directory is empty or the current directory.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
directory_text = str(directory).strip()
|
|
34
|
+
|
|
35
|
+
if directory_text in ('', '.'):
|
|
36
|
+
message = f'directory must be a real path (got "{directory}")'
|
|
37
|
+
raise ValueError(message)
|
|
38
|
+
|
|
39
|
+
self._directory = directory
|
|
40
|
+
self._page = page
|
|
41
|
+
self._sequence = 0
|
|
42
|
+
|
|
43
|
+
def screenshot(self, name: str) -> Path:
|
|
44
|
+
"""
|
|
45
|
+
A method that captures the current page to a numbered PNG file.
|
|
46
|
+
|
|
47
|
+
:param name: The label for the shot, with unusable characters replaced by a dash.
|
|
48
|
+
:return: The path the screenshot was written to.
|
|
49
|
+
:raises ValueError: If the name carries no characters usable in a file name.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name_clean = SCREENSHOT_NAME_INVALID_PATTERN.sub('-', name).strip('-.')
|
|
53
|
+
|
|
54
|
+
if not name_clean:
|
|
55
|
+
message = f'screenshot name carries no filename characters: "{name}"'
|
|
56
|
+
raise ValueError(message)
|
|
57
|
+
|
|
58
|
+
self._sequence += 1
|
|
59
|
+
|
|
60
|
+
path = self._directory / f'{self._sequence:02d}-{name_clean}.png'
|
|
61
|
+
|
|
62
|
+
self._directory.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
self._page.screenshot(path=str(path))
|
|
64
|
+
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
def switch_page(self, page: Page) -> None:
|
|
68
|
+
"""
|
|
69
|
+
A method that points the camera at a different page.
|
|
70
|
+
|
|
71
|
+
:param page: The page later screenshots are taken from.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
self._page = page
|