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.
Files changed (62) hide show
  1. limelight/__init__.py +47 -0
  2. limelight/application.py +102 -0
  3. limelight/artifacts.py +13 -0
  4. limelight/barriers.py +364 -0
  5. limelight/capture/__init__.py +1 -0
  6. limelight/capture/browser.py +81 -0
  7. limelight/capture/camera.py +74 -0
  8. limelight/capture/renderer.py +687 -0
  9. limelight/capture/sinks.py +205 -0
  10. limelight/components/__init__.py +16 -0
  11. limelight/components/confirm.py +84 -0
  12. limelight/components/dropdown.py +147 -0
  13. limelight/components/modal.py +348 -0
  14. limelight/components/navigator.py +129 -0
  15. limelight/components/search_select.py +197 -0
  16. limelight/config.py +284 -0
  17. limelight/demo.py +644 -0
  18. limelight/django/__init__.py +295 -0
  19. limelight/django/pytest_plugin.py +131 -0
  20. limelight/django/server.py +72 -0
  21. limelight/export/__init__.py +80 -0
  22. limelight/export/chapters.py +56 -0
  23. limelight/export/cli.py +94 -0
  24. limelight/export/subtitles.py +116 -0
  25. limelight/export/video.py +133 -0
  26. limelight/export/voiceover.py +130 -0
  27. limelight/export/walkthrough.py +305 -0
  28. limelight/ffmpeg.py +323 -0
  29. limelight/gestures.py +88 -0
  30. limelight/javascript.py +17 -0
  31. limelight/ledger.py +249 -0
  32. limelight/narrator.py +418 -0
  33. limelight/overlay/__init__.py +571 -0
  34. limelight/overlay/assets/animation.js +50 -0
  35. limelight/overlay/assets/api.js +301 -0
  36. limelight/overlay/assets/control.js +220 -0
  37. limelight/overlay/assets/cursor.js +77 -0
  38. limelight/overlay/assets/dom.js +37 -0
  39. limelight/overlay/assets/install.js +38 -0
  40. limelight/overlay/assets/keys.js +25 -0
  41. limelight/overlay/assets/overlay.css +331 -0
  42. limelight/overlay/assets/spot.js +47 -0
  43. limelight/overlay/assets.py +50 -0
  44. limelight/overlay/bridge.py +182 -0
  45. limelight/overlay/cursor.py +234 -0
  46. limelight/overlay/keyboard.py +138 -0
  47. limelight/overlay/playback.py +304 -0
  48. limelight/py.typed +0 -0
  49. limelight/pytest_plugin.py +485 -0
  50. limelight/scene.py +212 -0
  51. limelight/scripts/input_type.js +1 -0
  52. limelight/scripts/locator_label.js +14 -0
  53. limelight/scripts/point_hit.js +15 -0
  54. limelight/scripts/select_option_labels.js +1 -0
  55. limelight/theme.py +54 -0
  56. limelight/transcript.py +174 -0
  57. limelight/world.py +82 -0
  58. playwright_limelight-0.2.0.dist-info/METADATA +220 -0
  59. playwright_limelight-0.2.0.dist-info/RECORD +62 -0
  60. playwright_limelight-0.2.0.dist-info/WHEEL +4 -0
  61. playwright_limelight-0.2.0.dist-info/entry_points.txt +3 -0
  62. 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
+ ]
@@ -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