playwright-limelight 0.2.0__tar.gz

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. playwright_limelight-0.2.0/LICENSE +21 -0
  2. playwright_limelight-0.2.0/PKG-INFO +220 -0
  3. playwright_limelight-0.2.0/README.md +193 -0
  4. playwright_limelight-0.2.0/limelight/__init__.py +47 -0
  5. playwright_limelight-0.2.0/limelight/application.py +102 -0
  6. playwright_limelight-0.2.0/limelight/artifacts.py +13 -0
  7. playwright_limelight-0.2.0/limelight/barriers.py +364 -0
  8. playwright_limelight-0.2.0/limelight/capture/__init__.py +1 -0
  9. playwright_limelight-0.2.0/limelight/capture/browser.py +81 -0
  10. playwright_limelight-0.2.0/limelight/capture/camera.py +74 -0
  11. playwright_limelight-0.2.0/limelight/capture/renderer.py +687 -0
  12. playwright_limelight-0.2.0/limelight/capture/sinks.py +205 -0
  13. playwright_limelight-0.2.0/limelight/components/__init__.py +16 -0
  14. playwright_limelight-0.2.0/limelight/components/confirm.py +84 -0
  15. playwright_limelight-0.2.0/limelight/components/dropdown.py +147 -0
  16. playwright_limelight-0.2.0/limelight/components/modal.py +348 -0
  17. playwright_limelight-0.2.0/limelight/components/navigator.py +129 -0
  18. playwright_limelight-0.2.0/limelight/components/search_select.py +197 -0
  19. playwright_limelight-0.2.0/limelight/config.py +284 -0
  20. playwright_limelight-0.2.0/limelight/demo.py +644 -0
  21. playwright_limelight-0.2.0/limelight/django/__init__.py +295 -0
  22. playwright_limelight-0.2.0/limelight/django/pytest_plugin.py +131 -0
  23. playwright_limelight-0.2.0/limelight/django/server.py +72 -0
  24. playwright_limelight-0.2.0/limelight/export/__init__.py +80 -0
  25. playwright_limelight-0.2.0/limelight/export/chapters.py +56 -0
  26. playwright_limelight-0.2.0/limelight/export/cli.py +94 -0
  27. playwright_limelight-0.2.0/limelight/export/subtitles.py +116 -0
  28. playwright_limelight-0.2.0/limelight/export/video.py +133 -0
  29. playwright_limelight-0.2.0/limelight/export/voiceover.py +130 -0
  30. playwright_limelight-0.2.0/limelight/export/walkthrough.py +305 -0
  31. playwright_limelight-0.2.0/limelight/ffmpeg.py +323 -0
  32. playwright_limelight-0.2.0/limelight/gestures.py +88 -0
  33. playwright_limelight-0.2.0/limelight/javascript.py +17 -0
  34. playwright_limelight-0.2.0/limelight/ledger.py +249 -0
  35. playwright_limelight-0.2.0/limelight/narrator.py +418 -0
  36. playwright_limelight-0.2.0/limelight/overlay/__init__.py +571 -0
  37. playwright_limelight-0.2.0/limelight/overlay/assets/animation.js +50 -0
  38. playwright_limelight-0.2.0/limelight/overlay/assets/api.js +301 -0
  39. playwright_limelight-0.2.0/limelight/overlay/assets/control.js +220 -0
  40. playwright_limelight-0.2.0/limelight/overlay/assets/cursor.js +77 -0
  41. playwright_limelight-0.2.0/limelight/overlay/assets/dom.js +37 -0
  42. playwright_limelight-0.2.0/limelight/overlay/assets/install.js +38 -0
  43. playwright_limelight-0.2.0/limelight/overlay/assets/keys.js +25 -0
  44. playwright_limelight-0.2.0/limelight/overlay/assets/overlay.css +331 -0
  45. playwright_limelight-0.2.0/limelight/overlay/assets/spot.js +47 -0
  46. playwright_limelight-0.2.0/limelight/overlay/assets.py +50 -0
  47. playwright_limelight-0.2.0/limelight/overlay/bridge.py +182 -0
  48. playwright_limelight-0.2.0/limelight/overlay/cursor.py +234 -0
  49. playwright_limelight-0.2.0/limelight/overlay/keyboard.py +138 -0
  50. playwright_limelight-0.2.0/limelight/overlay/playback.py +304 -0
  51. playwright_limelight-0.2.0/limelight/py.typed +0 -0
  52. playwright_limelight-0.2.0/limelight/pytest_plugin.py +485 -0
  53. playwright_limelight-0.2.0/limelight/scene.py +212 -0
  54. playwright_limelight-0.2.0/limelight/scripts/input_type.js +1 -0
  55. playwright_limelight-0.2.0/limelight/scripts/locator_label.js +14 -0
  56. playwright_limelight-0.2.0/limelight/scripts/point_hit.js +15 -0
  57. playwright_limelight-0.2.0/limelight/scripts/select_option_labels.js +1 -0
  58. playwright_limelight-0.2.0/limelight/theme.py +54 -0
  59. playwright_limelight-0.2.0/limelight/transcript.py +174 -0
  60. playwright_limelight-0.2.0/limelight/world.py +82 -0
  61. playwright_limelight-0.2.0/pyproject.toml +51 -0
  62. playwright_limelight-0.2.0/pyproject.toml.orig +58 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stratus Advanced Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,220 @@
1
+ Metadata-Version: 2.4
2
+ Name: playwright-limelight
3
+ Version: 0.2.0
4
+ Summary: Narrated Playwright demos that double as silent end-to-end tests
5
+ Author: Brayden Carlson
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Dist: playwright>=1.49
11
+ Requires-Dist: django>=4 ; extra == 'development'
12
+ Requires-Dist: pytest>=8.0 ; extra == 'development'
13
+ Requires-Dist: pytest-django>=4.9 ; extra == 'development'
14
+ Requires-Dist: pytest-playwright>=0.7 ; extra == 'development'
15
+ Requires-Dist: ruff>=0.16 ; extra == 'development'
16
+ Requires-Dist: ty>=0.0.75 ; extra == 'development'
17
+ Requires-Dist: typing-extensions>=4.12 ; extra == 'development'
18
+ Requires-Dist: django>=4 ; extra == 'django'
19
+ Requires-Dist: pytest-django>=4.9 ; extra == 'django'
20
+ Requires-Dist: pytest>=8.0 ; extra == 'pytest'
21
+ Requires-Dist: pytest-playwright>=0.7 ; extra == 'pytest'
22
+ Requires-Python: >=3.11
23
+ Provides-Extra: development
24
+ Provides-Extra: django
25
+ Provides-Extra: pytest
26
+ Description-Content-Type: text/markdown
27
+
28
+ <p align="center">
29
+ <picture>
30
+ <source media="(prefers-color-scheme: dark)" srcset="assets/png/limelight-wordmark-1567x404.png">
31
+ <source media="(prefers-color-scheme: light)" srcset="assets/png/limelight-wordmark-1567x404.png">
32
+ <img alt="limelight" src="assets/png/limelight-wordmark-1567x404.png" width="320">
33
+ </picture>
34
+ </p>
35
+
36
+ &nbsp;
37
+
38
+ <p align="center">
39
+ A narration layer for Playwright that turns end-to-end tests into demos.
40
+ </p>
41
+
42
+ ## Overview
43
+
44
+ A limelight test is written once and runs in two modes. The silent mode is a plain headless e2e test, where each narration call is a no-op. The narrated mode runs the same test headed with an overlay of caption cards, spotlights, an animated cursor, screenshots, and video. A test body never branches on the mode. The `Demo` facade is the whole API.
45
+
46
+ ## Installation
47
+
48
+ Add limelight to your `pyproject.toml`:
49
+
50
+ ```toml
51
+ [project.optional-dependencies]
52
+ development = [
53
+ "playwright-limelight[django,pytest]",
54
+ ]
55
+ ```
56
+
57
+ Install it directly with pip instead:
58
+
59
+ ```
60
+ pip install "playwright-limelight[django,pytest]"
61
+ ```
62
+
63
+ The `django` extra installs the `limelight.django` adapter. The `pytest` extra installs `pytest` and `pytest-playwright` for the plugin.
64
+
65
+ ## Usage
66
+
67
+ Register the plugins in `conftest.py`. The first is Django-free; the second adds the live server and the page timeout a Django project needs:
68
+
69
+ ```python
70
+ pytest_plugins = ['limelight.pytest_plugin', 'limelight.django.pytest_plugin']
71
+ ```
72
+
73
+ Write the demo as a test:
74
+
75
+ ```python
76
+ from limelight import Demo
77
+ from limelight.django import DjangoApplication
78
+
79
+
80
+ def test_order_approval_demo(page, live_server, admin_user):
81
+ application = DjangoApplication(live_server=live_server)
82
+ demo = Demo(page, application, name='order-approval', user=admin_user)
83
+
84
+ demo.goto('home:dashboard')
85
+ demo.title('Order Approval')
86
+ demo.narrate('Open the order')
87
+ demo.click(page.get_by_role('link', name='Orders'))
88
+ ```
89
+
90
+ Run it silent, then narrated:
91
+
92
+ ```
93
+ pytest -k order_approval_demo
94
+ DEMO_MODE=narrate pytest -k order_approval_demo --headed
95
+ ```
96
+
97
+ Turn a narrated run into an mp4, a walkthrough, and subtitles:
98
+
99
+ ```
100
+ DEMO_MODE=narrate DEMO_VIDEO=1 pytest -k order_approval_demo
101
+ limelight-render .demos/order-approval --title "Order Approval"
102
+ ```
103
+
104
+ `DEMO_VIDEO` does not record the screen. The browser runs headless under Chrome's begin-frame control, and limelight advances the compositor one frame at a time, screenshotting each one and piping it into ffmpeg. Every hold, glide and transition is measured in frames rather than wall time, so `video.mp4` comes out with one distinct frame per interval no matter how slow the machine is. It needs `ffmpeg` on the PATH.
105
+
106
+ `DEMO_VIDEO_QUALITY` picks what that costs:
107
+
108
+ | Quality | Output | Use |
109
+ |---|---|---|
110
+ | `low` | 1920x1080, 24 fps | Iterating on pacing, where only the timing is being read back. |
111
+ | `medium` | 1920x1080, 30 fps | The default. Sharp enough to read the interface, and the cheapest of the three to produce. |
112
+ | `high` | 3840x2160, 60 fps | A released demo, where the render is done once and the file size does not matter. |
113
+
114
+ The cost is carried by the frame count and the pixel count together. Each frame is one
115
+ round trip to the compositor, so `high` asks for twice as many as `medium`, and each of
116
+ those frames covers four times the pixels at a device scale factor of 2. A `high` render
117
+ therefore takes around eight times the work of a `medium` one and leaves a file several
118
+ times larger.
119
+
120
+ ## Page objects
121
+
122
+ A `Scene` is one screen of the application. The subclass names the route, proves the screen
123
+ has loaded, and offers one method per action a viewer would take, so a test reads as a list
124
+ of steps rather than a list of selectors.
125
+
126
+ ```python
127
+ from limelight import Scene
128
+ from playwright.sync_api import expect
129
+
130
+
131
+ class OrderScene(Scene):
132
+ route = 'sales:order:page:list'
133
+
134
+ def expect_ready(self) -> None:
135
+ expect(self.search_field).to_be_visible()
136
+
137
+ @property
138
+ def search_field(self):
139
+ return self.demo.page.get_by_placeholder('Search orders')
140
+
141
+ def search(self, text: str) -> None:
142
+ self._teach_focus(self.search_field, headline='Search the orders', label='Search')
143
+
144
+ self._fill(self.search_field, text)
145
+ ```
146
+
147
+ The protected helpers are the vocabulary a scene is written in. `_click`, `_fill`, `_select`,
148
+ `_check`, `_press`, and `_hover` each perform the action through the demo and then hold, so a
149
+ narrated run shows the drawn pointer land before the next step begins and a silent run pays
150
+ nothing for the hold. `_tab` opens a tab by its name. `_teach_focus` waits for an element,
151
+ narrates it, and spotlights it in one step, which makes it a barrier in silent mode as well
152
+ as a caption in a narrated one; `_teach_click` does the same and then clicks.
153
+
154
+ A scene never calls a Playwright locator method directly, because a bare click moves the real
155
+ mouse without moving the drawn one, and the click lands with the pointer somewhere else on
156
+ screen.
157
+
158
+ ## Components
159
+
160
+ A widget that appears on many screens is a component rather than a method on each scene. Each
161
+ one carries its selectors as class attributes, so markup that differs is a subclass overriding
162
+ a selector rather than a fork of the driver.
163
+
164
+ | Component | What it drives |
165
+ |---|---|
166
+ | `Modal` | A dialog: opening it from its control, filling it by label, and submitting it. |
167
+ | `Dropdown` | A menu that opens from a trigger and lists its actions. |
168
+ | `Confirm` | The inline prompt that stands between an action and its effect. |
169
+ | `SearchAndSelect` | A dropdown field that filters a long list and picks one choice. |
170
+ | `Navigator` | A navigation menu, addressed by the text a viewer reads on its links. |
171
+
172
+ ```python
173
+ from limelight import Confirm, Dropdown
174
+
175
+
176
+ class RowMenu(Dropdown):
177
+ trigger_selector = '.bi-three-dots-vertical'
178
+
179
+
180
+ class DestructiveConfirm(Confirm):
181
+ button_selector = '.btn-danger'
182
+
183
+
184
+ RowMenu(demo, row).choose('Delete')
185
+ DestructiveConfirm(demo).accept('Delete')
186
+ ```
187
+
188
+ `Dropdown` scopes its trigger, its menu, and its actions to the region it is given, so a page
189
+ holding one menu per row can address a single row without a menu left open elsewhere
190
+ answering for it. `Confirm` leaves its click unbarriered on purpose, because a prompt sits in
191
+ front of anything from a form post to a background write, so the caller wraps the accept in
192
+ the barrier that proves its own effect landed.
193
+
194
+ ## Barriers
195
+
196
+ Silent mode removes every time cushion, so correctness rests on retrying barriers rather than
197
+ on holds. `trigger_until_navigation`, `trigger_until_response`, and `trigger_until_visible`
198
+ each repeat a trigger until its effect arrives, because a click can land before the handler
199
+ that listens for it is bound and a lost click leaves the page where it was with no error to
200
+ catch. `trigger_until_response` matches by a substring of the response URL, by the method its
201
+ request was made with, or by a predicate, exactly one at a time.
202
+
203
+ ```python
204
+ trigger_until_response(page, lambda: demo.click(submit), method='POST')
205
+ ```
206
+
207
+ `Demo.follow` is the barriered form of clicking a link: it spotlights the link, clicks it with
208
+ the drawn pointer, and waits out the page it leads to.
209
+
210
+ ## Configuration
211
+
212
+ | Variable | Description | Default |
213
+ |---|---|---|
214
+ | `DEMO_MODE` | The narration and viewport policy, one of `silent`, `narrate`, or `present`. | `silent` |
215
+ | `DEMO_SPEED` | The playback speed the demo starts at, one of `normal`, `fast`, `faster`, or `turbo`. | `normal` |
216
+ | `DEMO_STEP_MS` | The base hold length in milliseconds. | `4500` |
217
+ | `DEMO_SHOTS` | Whether a narrated run writes screenshots. | off |
218
+ | `DEMO_VIDEO` | Whether a narrated run renders `video.mp4` frame by frame in a headless browser. | off |
219
+ | `DEMO_VIDEO_QUALITY` | The resolution, frame rate and encoder settings, one of `low`, `medium`, or `high`. | `medium` |
220
+ | `DEMO_CURSOR_HIDDEN` | Whether the drawn pointer is left off a narrated run. | off |
@@ -0,0 +1,193 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/png/limelight-wordmark-1567x404.png">
4
+ <source media="(prefers-color-scheme: light)" srcset="assets/png/limelight-wordmark-1567x404.png">
5
+ <img alt="limelight" src="assets/png/limelight-wordmark-1567x404.png" width="320">
6
+ </picture>
7
+ </p>
8
+
9
+ &nbsp;
10
+
11
+ <p align="center">
12
+ A narration layer for Playwright that turns end-to-end tests into demos.
13
+ </p>
14
+
15
+ ## Overview
16
+
17
+ A limelight test is written once and runs in two modes. The silent mode is a plain headless e2e test, where each narration call is a no-op. The narrated mode runs the same test headed with an overlay of caption cards, spotlights, an animated cursor, screenshots, and video. A test body never branches on the mode. The `Demo` facade is the whole API.
18
+
19
+ ## Installation
20
+
21
+ Add limelight to your `pyproject.toml`:
22
+
23
+ ```toml
24
+ [project.optional-dependencies]
25
+ development = [
26
+ "playwright-limelight[django,pytest]",
27
+ ]
28
+ ```
29
+
30
+ Install it directly with pip instead:
31
+
32
+ ```
33
+ pip install "playwright-limelight[django,pytest]"
34
+ ```
35
+
36
+ The `django` extra installs the `limelight.django` adapter. The `pytest` extra installs `pytest` and `pytest-playwright` for the plugin.
37
+
38
+ ## Usage
39
+
40
+ Register the plugins in `conftest.py`. The first is Django-free; the second adds the live server and the page timeout a Django project needs:
41
+
42
+ ```python
43
+ pytest_plugins = ['limelight.pytest_plugin', 'limelight.django.pytest_plugin']
44
+ ```
45
+
46
+ Write the demo as a test:
47
+
48
+ ```python
49
+ from limelight import Demo
50
+ from limelight.django import DjangoApplication
51
+
52
+
53
+ def test_order_approval_demo(page, live_server, admin_user):
54
+ application = DjangoApplication(live_server=live_server)
55
+ demo = Demo(page, application, name='order-approval', user=admin_user)
56
+
57
+ demo.goto('home:dashboard')
58
+ demo.title('Order Approval')
59
+ demo.narrate('Open the order')
60
+ demo.click(page.get_by_role('link', name='Orders'))
61
+ ```
62
+
63
+ Run it silent, then narrated:
64
+
65
+ ```
66
+ pytest -k order_approval_demo
67
+ DEMO_MODE=narrate pytest -k order_approval_demo --headed
68
+ ```
69
+
70
+ Turn a narrated run into an mp4, a walkthrough, and subtitles:
71
+
72
+ ```
73
+ DEMO_MODE=narrate DEMO_VIDEO=1 pytest -k order_approval_demo
74
+ limelight-render .demos/order-approval --title "Order Approval"
75
+ ```
76
+
77
+ `DEMO_VIDEO` does not record the screen. The browser runs headless under Chrome's begin-frame control, and limelight advances the compositor one frame at a time, screenshotting each one and piping it into ffmpeg. Every hold, glide and transition is measured in frames rather than wall time, so `video.mp4` comes out with one distinct frame per interval no matter how slow the machine is. It needs `ffmpeg` on the PATH.
78
+
79
+ `DEMO_VIDEO_QUALITY` picks what that costs:
80
+
81
+ | Quality | Output | Use |
82
+ |---|---|---|
83
+ | `low` | 1920x1080, 24 fps | Iterating on pacing, where only the timing is being read back. |
84
+ | `medium` | 1920x1080, 30 fps | The default. Sharp enough to read the interface, and the cheapest of the three to produce. |
85
+ | `high` | 3840x2160, 60 fps | A released demo, where the render is done once and the file size does not matter. |
86
+
87
+ The cost is carried by the frame count and the pixel count together. Each frame is one
88
+ round trip to the compositor, so `high` asks for twice as many as `medium`, and each of
89
+ those frames covers four times the pixels at a device scale factor of 2. A `high` render
90
+ therefore takes around eight times the work of a `medium` one and leaves a file several
91
+ times larger.
92
+
93
+ ## Page objects
94
+
95
+ A `Scene` is one screen of the application. The subclass names the route, proves the screen
96
+ has loaded, and offers one method per action a viewer would take, so a test reads as a list
97
+ of steps rather than a list of selectors.
98
+
99
+ ```python
100
+ from limelight import Scene
101
+ from playwright.sync_api import expect
102
+
103
+
104
+ class OrderScene(Scene):
105
+ route = 'sales:order:page:list'
106
+
107
+ def expect_ready(self) -> None:
108
+ expect(self.search_field).to_be_visible()
109
+
110
+ @property
111
+ def search_field(self):
112
+ return self.demo.page.get_by_placeholder('Search orders')
113
+
114
+ def search(self, text: str) -> None:
115
+ self._teach_focus(self.search_field, headline='Search the orders', label='Search')
116
+
117
+ self._fill(self.search_field, text)
118
+ ```
119
+
120
+ The protected helpers are the vocabulary a scene is written in. `_click`, `_fill`, `_select`,
121
+ `_check`, `_press`, and `_hover` each perform the action through the demo and then hold, so a
122
+ narrated run shows the drawn pointer land before the next step begins and a silent run pays
123
+ nothing for the hold. `_tab` opens a tab by its name. `_teach_focus` waits for an element,
124
+ narrates it, and spotlights it in one step, which makes it a barrier in silent mode as well
125
+ as a caption in a narrated one; `_teach_click` does the same and then clicks.
126
+
127
+ A scene never calls a Playwright locator method directly, because a bare click moves the real
128
+ mouse without moving the drawn one, and the click lands with the pointer somewhere else on
129
+ screen.
130
+
131
+ ## Components
132
+
133
+ A widget that appears on many screens is a component rather than a method on each scene. Each
134
+ one carries its selectors as class attributes, so markup that differs is a subclass overriding
135
+ a selector rather than a fork of the driver.
136
+
137
+ | Component | What it drives |
138
+ |---|---|
139
+ | `Modal` | A dialog: opening it from its control, filling it by label, and submitting it. |
140
+ | `Dropdown` | A menu that opens from a trigger and lists its actions. |
141
+ | `Confirm` | The inline prompt that stands between an action and its effect. |
142
+ | `SearchAndSelect` | A dropdown field that filters a long list and picks one choice. |
143
+ | `Navigator` | A navigation menu, addressed by the text a viewer reads on its links. |
144
+
145
+ ```python
146
+ from limelight import Confirm, Dropdown
147
+
148
+
149
+ class RowMenu(Dropdown):
150
+ trigger_selector = '.bi-three-dots-vertical'
151
+
152
+
153
+ class DestructiveConfirm(Confirm):
154
+ button_selector = '.btn-danger'
155
+
156
+
157
+ RowMenu(demo, row).choose('Delete')
158
+ DestructiveConfirm(demo).accept('Delete')
159
+ ```
160
+
161
+ `Dropdown` scopes its trigger, its menu, and its actions to the region it is given, so a page
162
+ holding one menu per row can address a single row without a menu left open elsewhere
163
+ answering for it. `Confirm` leaves its click unbarriered on purpose, because a prompt sits in
164
+ front of anything from a form post to a background write, so the caller wraps the accept in
165
+ the barrier that proves its own effect landed.
166
+
167
+ ## Barriers
168
+
169
+ Silent mode removes every time cushion, so correctness rests on retrying barriers rather than
170
+ on holds. `trigger_until_navigation`, `trigger_until_response`, and `trigger_until_visible`
171
+ each repeat a trigger until its effect arrives, because a click can land before the handler
172
+ that listens for it is bound and a lost click leaves the page where it was with no error to
173
+ catch. `trigger_until_response` matches by a substring of the response URL, by the method its
174
+ request was made with, or by a predicate, exactly one at a time.
175
+
176
+ ```python
177
+ trigger_until_response(page, lambda: demo.click(submit), method='POST')
178
+ ```
179
+
180
+ `Demo.follow` is the barriered form of clicking a link: it spotlights the link, clicks it with
181
+ the drawn pointer, and waits out the page it leads to.
182
+
183
+ ## Configuration
184
+
185
+ | Variable | Description | Default |
186
+ |---|---|---|
187
+ | `DEMO_MODE` | The narration and viewport policy, one of `silent`, `narrate`, or `present`. | `silent` |
188
+ | `DEMO_SPEED` | The playback speed the demo starts at, one of `normal`, `fast`, `faster`, or `turbo`. | `normal` |
189
+ | `DEMO_STEP_MS` | The base hold length in milliseconds. | `4500` |
190
+ | `DEMO_SHOTS` | Whether a narrated run writes screenshots. | off |
191
+ | `DEMO_VIDEO` | Whether a narrated run renders `video.mp4` frame by frame in a headless browser. | off |
192
+ | `DEMO_VIDEO_QUALITY` | The resolution, frame rate and encoder settings, one of `low`, `medium`, or `high`. | `medium` |
193
+ | `DEMO_CURSOR_HIDDEN` | Whether the drawn pointer is left off a narrated run. | off |
@@ -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}'
@@ -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'