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.
- playwright_limelight-0.2.0/LICENSE +21 -0
- playwright_limelight-0.2.0/PKG-INFO +220 -0
- playwright_limelight-0.2.0/README.md +193 -0
- playwright_limelight-0.2.0/limelight/__init__.py +47 -0
- playwright_limelight-0.2.0/limelight/application.py +102 -0
- playwright_limelight-0.2.0/limelight/artifacts.py +13 -0
- playwright_limelight-0.2.0/limelight/barriers.py +364 -0
- playwright_limelight-0.2.0/limelight/capture/__init__.py +1 -0
- playwright_limelight-0.2.0/limelight/capture/browser.py +81 -0
- playwright_limelight-0.2.0/limelight/capture/camera.py +74 -0
- playwright_limelight-0.2.0/limelight/capture/renderer.py +687 -0
- playwright_limelight-0.2.0/limelight/capture/sinks.py +205 -0
- playwright_limelight-0.2.0/limelight/components/__init__.py +16 -0
- playwright_limelight-0.2.0/limelight/components/confirm.py +84 -0
- playwright_limelight-0.2.0/limelight/components/dropdown.py +147 -0
- playwright_limelight-0.2.0/limelight/components/modal.py +348 -0
- playwright_limelight-0.2.0/limelight/components/navigator.py +129 -0
- playwright_limelight-0.2.0/limelight/components/search_select.py +197 -0
- playwright_limelight-0.2.0/limelight/config.py +284 -0
- playwright_limelight-0.2.0/limelight/demo.py +644 -0
- playwright_limelight-0.2.0/limelight/django/__init__.py +295 -0
- playwright_limelight-0.2.0/limelight/django/pytest_plugin.py +131 -0
- playwright_limelight-0.2.0/limelight/django/server.py +72 -0
- playwright_limelight-0.2.0/limelight/export/__init__.py +80 -0
- playwright_limelight-0.2.0/limelight/export/chapters.py +56 -0
- playwright_limelight-0.2.0/limelight/export/cli.py +94 -0
- playwright_limelight-0.2.0/limelight/export/subtitles.py +116 -0
- playwright_limelight-0.2.0/limelight/export/video.py +133 -0
- playwright_limelight-0.2.0/limelight/export/voiceover.py +130 -0
- playwright_limelight-0.2.0/limelight/export/walkthrough.py +305 -0
- playwright_limelight-0.2.0/limelight/ffmpeg.py +323 -0
- playwright_limelight-0.2.0/limelight/gestures.py +88 -0
- playwright_limelight-0.2.0/limelight/javascript.py +17 -0
- playwright_limelight-0.2.0/limelight/ledger.py +249 -0
- playwright_limelight-0.2.0/limelight/narrator.py +418 -0
- playwright_limelight-0.2.0/limelight/overlay/__init__.py +571 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/animation.js +50 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/api.js +301 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/control.js +220 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/cursor.js +77 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/dom.js +37 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/install.js +38 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/keys.js +25 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/overlay.css +331 -0
- playwright_limelight-0.2.0/limelight/overlay/assets/spot.js +47 -0
- playwright_limelight-0.2.0/limelight/overlay/assets.py +50 -0
- playwright_limelight-0.2.0/limelight/overlay/bridge.py +182 -0
- playwright_limelight-0.2.0/limelight/overlay/cursor.py +234 -0
- playwright_limelight-0.2.0/limelight/overlay/keyboard.py +138 -0
- playwright_limelight-0.2.0/limelight/overlay/playback.py +304 -0
- playwright_limelight-0.2.0/limelight/py.typed +0 -0
- playwright_limelight-0.2.0/limelight/pytest_plugin.py +485 -0
- playwright_limelight-0.2.0/limelight/scene.py +212 -0
- playwright_limelight-0.2.0/limelight/scripts/input_type.js +1 -0
- playwright_limelight-0.2.0/limelight/scripts/locator_label.js +14 -0
- playwright_limelight-0.2.0/limelight/scripts/point_hit.js +15 -0
- playwright_limelight-0.2.0/limelight/scripts/select_option_labels.js +1 -0
- playwright_limelight-0.2.0/limelight/theme.py +54 -0
- playwright_limelight-0.2.0/limelight/transcript.py +174 -0
- playwright_limelight-0.2.0/limelight/world.py +82 -0
- playwright_limelight-0.2.0/pyproject.toml +51 -0
- 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
|
+
|
|
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
|
+
|
|
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'
|