prettyplay 0.0.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.
Potentially problematic release.
This version of prettyplay might be problematic. Click here for more details.
- prettyplay/.usages/lifecycle.md +50 -0
- prettyplay/.usages/steps.md +56 -0
- prettyplay/CODEMANIFEST +182 -0
- prettyplay/__init__.py +13 -0
- prettyplay/cache/.usages/addressing.md +31 -0
- prettyplay/cache/.usages/budgets.md +21 -0
- prettyplay/cache/.usages/storage.md +32 -0
- prettyplay/cache/CODEMANIFEST +152 -0
- prettyplay/cache/__init__.py +8 -0
- prettyplay/cache/budgets.py +73 -0
- prettyplay/cache/models.py +60 -0
- prettyplay/cache/store.py +260 -0
- prettyplay/cache/text.py +35 -0
- prettyplay/config/.usages/configuration.md +87 -0
- prettyplay/config/CODEMANIFEST +132 -0
- prettyplay/config/__init__.py +6 -0
- prettyplay/config/loader.py +192 -0
- prettyplay/config/models.py +92 -0
- prettyplay/driver/.usages/facade.md +66 -0
- prettyplay/driver/CODEMANIFEST +162 -0
- prettyplay/driver/__init__.py +6 -0
- prettyplay/driver/page.py +350 -0
- prettyplay/driver/session.py +288 -0
- prettyplay/engine/.usages/generation.md +45 -0
- prettyplay/engine/.usages/healing.md +31 -0
- prettyplay/engine/CODEMANIFEST +215 -0
- prettyplay/engine/__init__.py +8 -0
- prettyplay/engine/classification.py +69 -0
- prettyplay/engine/execution.py +25 -0
- prettyplay/engine/generator.py +318 -0
- prettyplay/engine/healer.py +116 -0
- prettyplay/engine/text.py +19 -0
- prettyplay/executor.py +109 -0
- prettyplay/failures/.usages/taxonomy.md +40 -0
- prettyplay/failures/CODEMANIFEST +117 -0
- prettyplay/failures/__init__.py +17 -0
- prettyplay/failures/errors.py +147 -0
- prettyplay/llm/.usages/classification.md +25 -0
- prettyplay/llm/.usages/providers.md +33 -0
- prettyplay/llm/CODEMANIFEST +125 -0
- prettyplay/llm/__init__.py +8 -0
- prettyplay/llm/_request.py +216 -0
- prettyplay/llm/anthropic_provider.py +213 -0
- prettyplay/llm/models.py +22 -0
- prettyplay/llm/openai_provider.py +187 -0
- prettyplay/llm/provider.py +115 -0
- prettyplay/reporting/.usages/hooks.md +41 -0
- prettyplay/reporting/CODEMANIFEST +79 -0
- prettyplay/reporting/__init__.py +6 -0
- prettyplay/reporting/hooks.py +37 -0
- prettyplay/reporting/reporter.py +70 -0
- prettyplay/runtime.py +107 -0
- prettyplay/scenario.py +291 -0
- prettyplay-0.0.0.dist-info/METADATA +236 -0
- prettyplay-0.0.0.dist-info/RECORD +58 -0
- prettyplay-0.0.0.dist-info/WHEEL +5 -0
- prettyplay-0.0.0.dist-info/licenses/LICENSE +28 -0
- prettyplay-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""The narrow, stable facade of a single test page and of one located element.
|
|
2
|
+
|
|
3
|
+
The facade is the single page API the generated step code may work through —
|
|
4
|
+
a backward-compatibility contract: it may only grow, never rename or remove.
|
|
5
|
+
No raw Playwright object crosses the boundary; every return value is a plain
|
|
6
|
+
``str``/``bytes`` or another facade. Auto-wait lives inside Playwright, so the
|
|
7
|
+
facade never sleeps and never applies fixed delays. When the page belongs to a
|
|
8
|
+
live driver session, every Playwright call is marshalled into the session's
|
|
9
|
+
driver thread; a facade built without a worker (hand-built in tests) calls
|
|
10
|
+
Playwright inline in the constructing thread.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
17
|
+
|
|
18
|
+
from playwright.sync_api import BrowserContext, Locator, Page, expect
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .session import PlaywrightWorker
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
|
|
25
|
+
_SCROLL_INTO_VIEW_JS = """(el, target) => {
|
|
26
|
+
const cr = el.getBoundingClientRect();
|
|
27
|
+
const tr = target.getBoundingClientRect();
|
|
28
|
+
el.scrollTop += tr.top - cr.top - (el.clientHeight - tr.height) / 2;
|
|
29
|
+
}"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PageFacade:
|
|
33
|
+
"""The only page API the generated step code may use.
|
|
34
|
+
|
|
35
|
+
Wraps one isolated browser context created by
|
|
36
|
+
:class:`~prettyplay.driver.session.DriverSession`; ``close`` closes the
|
|
37
|
+
context and leaves the browser of the test running.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
_page: the wrapped Playwright page; never exposed through the facade.
|
|
41
|
+
_context: the isolated context owning the page; the facade boundary.
|
|
42
|
+
_worker: the driver thread of the owning session; ``None`` for
|
|
43
|
+
hand-built facades, which then call Playwright inline.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, page: Page, context: BrowserContext) -> None:
|
|
47
|
+
"""Wrap one Playwright page of an isolated context.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
page: the Playwright page object; never exposed through the facade.
|
|
51
|
+
context: the isolated context of the page; the facade boundary.
|
|
52
|
+
"""
|
|
53
|
+
self._page = page
|
|
54
|
+
self._context = context
|
|
55
|
+
self._worker: PlaywrightWorker | None = None
|
|
56
|
+
|
|
57
|
+
def _call(self, fn: Callable[[], _T]) -> _T:
|
|
58
|
+
"""Run one Playwright-touching callable in the driver thread.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
fn: the callable touching the wrapped Playwright objects.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Whatever ``fn`` returns.
|
|
65
|
+
"""
|
|
66
|
+
if self._worker is None:
|
|
67
|
+
return fn()
|
|
68
|
+
|
|
69
|
+
return self._worker.run(fn)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def url(self) -> str:
|
|
73
|
+
"""The current URL of the page."""
|
|
74
|
+
return self._call(lambda: self._page.url)
|
|
75
|
+
|
|
76
|
+
def open(self, url: str) -> None:
|
|
77
|
+
"""Navigate to the URL and wait for the load event.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
url: the address to open.
|
|
81
|
+
"""
|
|
82
|
+
self._call(lambda: self._page.goto(url))
|
|
83
|
+
|
|
84
|
+
def find_by_role(self, role: str, name: str) -> LocatorFacade:
|
|
85
|
+
"""Find an element by its aria role and accessible name.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
role: the aria role of the element, e.g. ``button``.
|
|
89
|
+
name: the accessible name of the element.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The facade of the located element.
|
|
93
|
+
"""
|
|
94
|
+
locator = self._call(lambda: self._page.get_by_role(role, name=name))
|
|
95
|
+
return self._wrap_locator(locator)
|
|
96
|
+
|
|
97
|
+
def find_by_label(self, label: str) -> LocatorFacade:
|
|
98
|
+
"""Find a form element by its associated label.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
label: the text of the label associated with the element.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The facade of the located element.
|
|
105
|
+
"""
|
|
106
|
+
locator = self._call(lambda: self._page.get_by_label(label))
|
|
107
|
+
return self._wrap_locator(locator)
|
|
108
|
+
|
|
109
|
+
def find_by_text(self, text: str) -> LocatorFacade:
|
|
110
|
+
"""Find an element by its visible text.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
text: the visible text of the element.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The facade of the located element.
|
|
117
|
+
"""
|
|
118
|
+
locator = self._call(lambda: self._page.get_by_text(text))
|
|
119
|
+
return self._wrap_locator(locator)
|
|
120
|
+
|
|
121
|
+
def find_by_attribute(self, name: str, value: str) -> LocatorFacade:
|
|
122
|
+
"""Find an element by the value of one of its attributes.
|
|
123
|
+
|
|
124
|
+
Intended for data-* attributes (e.g. ``data-test-id``); the CSS engine
|
|
125
|
+
handles the attribute selector natively and the returned handle
|
|
126
|
+
auto-waits exactly like the other locating methods.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
name: the full attribute name, e.g. ``data-test-id``.
|
|
130
|
+
value: the attribute value to match.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The facade of the located element.
|
|
134
|
+
"""
|
|
135
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
136
|
+
locator = self._call(lambda: self._page.locator(f'[{name}="{escaped}"]'))
|
|
137
|
+
return self._wrap_locator(locator)
|
|
138
|
+
|
|
139
|
+
def find_by_css(self, selector: str) -> LocatorFacade:
|
|
140
|
+
"""Find an element by a CSS selector.
|
|
141
|
+
|
|
142
|
+
The selector is passed through verbatim — escaping belongs to the
|
|
143
|
+
caller, a universal escaping would break ``>``/``+`` combinators.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
selector: a valid CSS selector expression, e.g. ``form > button.primary``.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The facade of the located element.
|
|
150
|
+
"""
|
|
151
|
+
locator = self._call(lambda: self._page.locator(selector))
|
|
152
|
+
return self._wrap_locator(locator)
|
|
153
|
+
|
|
154
|
+
def find_by_xpath(self, xpath: str) -> LocatorFacade:
|
|
155
|
+
"""Find an element by an XPath expression.
|
|
156
|
+
|
|
157
|
+
The explicit ``xpath=`` engine prefix keeps every expression uniform:
|
|
158
|
+
Playwright sniffs XPath implicitly only via a ``//`` or ``..`` prefix,
|
|
159
|
+
so an expression such as ``*[@id='main']`` would otherwise silently go
|
|
160
|
+
to the CSS engine.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
xpath: a valid XPath expression, e.g. ``//button[@type='submit']``.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The facade of the located element.
|
|
167
|
+
"""
|
|
168
|
+
locator = self._call(lambda: self._page.locator(f"xpath={xpath}"))
|
|
169
|
+
return self._wrap_locator(locator)
|
|
170
|
+
|
|
171
|
+
def aria_snapshot(self) -> str:
|
|
172
|
+
"""Capture the accessibility-tree state of the page.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The aria snapshot of the page body.
|
|
176
|
+
"""
|
|
177
|
+
return self._call(lambda: self._page.locator("body").aria_snapshot())
|
|
178
|
+
|
|
179
|
+
def screenshot(self) -> bytes:
|
|
180
|
+
"""Capture a full-page screenshot.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The PNG image of the whole page as bytes.
|
|
184
|
+
"""
|
|
185
|
+
return self._call(lambda: self._page.screenshot(full_page=True))
|
|
186
|
+
|
|
187
|
+
def scroll_to_element(self, element: LocatorFacade) -> None:
|
|
188
|
+
"""Scroll the page so the element enters the viewport.
|
|
189
|
+
|
|
190
|
+
Works inside the nearest scrollable ancestor when the element lives in
|
|
191
|
+
a scrollable container; the scrolled state is awaited by the follow-up
|
|
192
|
+
locators and expectations, never by a delay.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
element: the located element to bring into view.
|
|
196
|
+
"""
|
|
197
|
+
self._call(element._locator.scroll_into_view_if_needed)
|
|
198
|
+
|
|
199
|
+
def scroll_down(self, pixels: int) -> None:
|
|
200
|
+
"""Scroll the page down by an amount.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
pixels: a positive scroll amount in CSS pixels.
|
|
204
|
+
"""
|
|
205
|
+
self._call(lambda: self._page.mouse.wheel(0, pixels))
|
|
206
|
+
|
|
207
|
+
def scroll_up(self, pixels: int) -> None:
|
|
208
|
+
"""Scroll the page up by an amount.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
pixels: a positive scroll amount in CSS pixels.
|
|
212
|
+
"""
|
|
213
|
+
self._call(lambda: self._page.mouse.wheel(0, -pixels))
|
|
214
|
+
|
|
215
|
+
def scroll_to_bottom(self) -> None:
|
|
216
|
+
"""Scroll the page to its end."""
|
|
217
|
+
self._call(lambda: self._page.evaluate("window.scrollTo(0, document.body.scrollHeight)"))
|
|
218
|
+
|
|
219
|
+
def scroll_to_top(self) -> None:
|
|
220
|
+
"""Scroll the page to its start."""
|
|
221
|
+
self._call(lambda: self._page.evaluate("window.scrollTo(0, 0)"))
|
|
222
|
+
|
|
223
|
+
def scroll_into_view(self, element: LocatorFacade, container: LocatorFacade) -> None:
|
|
224
|
+
"""Bring the element into the visible area of the specific scrollable container.
|
|
225
|
+
|
|
226
|
+
For nested scrollables where the nearest-ancestor behavior of
|
|
227
|
+
``scroll_to_element`` is not enough. The target is resolved through
|
|
228
|
+
``element_handle()`` (which auto-waits) and passed to the container
|
|
229
|
+
evaluation as the live handle argument.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
element: the located element to bring into view.
|
|
233
|
+
container: the located scrollable container, e.g. a carousel.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def scroll() -> None:
|
|
237
|
+
handle = element._locator.element_handle()
|
|
238
|
+
container._locator.evaluate(_SCROLL_INTO_VIEW_JS, handle)
|
|
239
|
+
|
|
240
|
+
self._call(scroll)
|
|
241
|
+
|
|
242
|
+
def scroll_container_down(self, container: LocatorFacade, pixels: int) -> None:
|
|
243
|
+
"""Scroll the scrollable container down by an amount.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
container: the located scrollable container.
|
|
247
|
+
pixels: a positive scroll amount in CSS pixels.
|
|
248
|
+
"""
|
|
249
|
+
self._call(lambda: container._locator.evaluate("(el, px) => { el.scrollTop += px; }", pixels))
|
|
250
|
+
|
|
251
|
+
def scroll_container_up(self, container: LocatorFacade, pixels: int) -> None:
|
|
252
|
+
"""Scroll the scrollable container up by an amount.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
container: the located scrollable container.
|
|
256
|
+
pixels: a positive scroll amount in CSS pixels.
|
|
257
|
+
"""
|
|
258
|
+
self._call(lambda: container._locator.evaluate("(el, px) => { el.scrollTop -= px; }", pixels))
|
|
259
|
+
|
|
260
|
+
def close(self) -> None:
|
|
261
|
+
"""Close the isolated context of the page; the test's browser keeps running."""
|
|
262
|
+
self._call(self._context.close)
|
|
263
|
+
|
|
264
|
+
def _wrap_locator(self, locator: Locator) -> LocatorFacade:
|
|
265
|
+
"""Wrap a located element, inheriting the driver thread boundary.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
locator: the Playwright locator object; never exposed.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The facade of the located element.
|
|
272
|
+
"""
|
|
273
|
+
facade = LocatorFacade(locator)
|
|
274
|
+
facade._worker = self._worker
|
|
275
|
+
|
|
276
|
+
return facade
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class LocatorFacade:
|
|
280
|
+
"""An auto-waiting handle of one located element.
|
|
281
|
+
|
|
282
|
+
Actions and expectations delegate to the wrapped locator; expectations go
|
|
283
|
+
through Playwright ``expect`` and raise ``AssertionError`` on failure, so a
|
|
284
|
+
broken expectation reaches failure classification untouched.
|
|
285
|
+
|
|
286
|
+
Attributes:
|
|
287
|
+
_locator: the wrapped Playwright locator; never exposed through the facade.
|
|
288
|
+
_worker: the driver thread inherited from the page facade that created
|
|
289
|
+
this handle; ``None`` for hand-built handles, which then call
|
|
290
|
+
Playwright inline.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def __init__(self, locator: Locator) -> None:
|
|
294
|
+
"""Wrap one Playwright locator.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
locator: the Playwright locator object; never exposed through the facade.
|
|
298
|
+
"""
|
|
299
|
+
self._locator = locator
|
|
300
|
+
self._worker: PlaywrightWorker | None = None
|
|
301
|
+
|
|
302
|
+
def _call(self, fn: Callable[[], _T]) -> _T:
|
|
303
|
+
"""Run one Playwright-touching callable in the driver thread.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
fn: the callable touching the wrapped Playwright objects.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Whatever ``fn`` returns.
|
|
310
|
+
"""
|
|
311
|
+
if self._worker is None:
|
|
312
|
+
return fn()
|
|
313
|
+
|
|
314
|
+
return self._worker.run(fn)
|
|
315
|
+
|
|
316
|
+
def click(self) -> None:
|
|
317
|
+
"""Click the element with auto-wait."""
|
|
318
|
+
self._call(self._locator.click)
|
|
319
|
+
|
|
320
|
+
def fill(self, value: str) -> None:
|
|
321
|
+
"""Set the text input value of the element.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
value: the text to type into the element.
|
|
325
|
+
"""
|
|
326
|
+
self._call(lambda: self._locator.fill(value))
|
|
327
|
+
|
|
328
|
+
def select_option(self, value: str) -> None:
|
|
329
|
+
"""Choose one option of the element.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
value: the value of the option to choose.
|
|
333
|
+
"""
|
|
334
|
+
self._call(lambda: self._locator.select_option(value))
|
|
335
|
+
|
|
336
|
+
def expect_visible(self) -> None:
|
|
337
|
+
"""Assert the element is visible."""
|
|
338
|
+
self._call(lambda: expect(self._locator).to_be_visible())
|
|
339
|
+
|
|
340
|
+
def expect_text(self, text: str) -> None:
|
|
341
|
+
"""Assert the element contains the text.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
text: the text the element must contain.
|
|
345
|
+
"""
|
|
346
|
+
self._call(lambda: expect(self._locator).to_contain_text(text))
|
|
347
|
+
|
|
348
|
+
def expect_enabled(self) -> None:
|
|
349
|
+
"""Assert the element is enabled."""
|
|
350
|
+
self._call(lambda: expect(self._locator).to_be_enabled())
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Lifecycle owner of the Playwright sync driver and the browser process of one test."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from playwright.sync_api import Browser, BrowserContext, Error, Page, Playwright, sync_playwright
|
|
11
|
+
|
|
12
|
+
from ..config import Config
|
|
13
|
+
from .page import PageFacade
|
|
14
|
+
|
|
15
|
+
_T = TypeVar("_T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Task:
|
|
19
|
+
"""One unit of work crossing from the caller thread into the driver thread.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
fn: the callable executed by the worker pump.
|
|
23
|
+
done: set once fn returned or raised in the worker thread.
|
|
24
|
+
result: the return value of fn; ``None`` until done.
|
|
25
|
+
error: the exception fn raised, if any.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("done", "error", "fn", "result")
|
|
29
|
+
|
|
30
|
+
def __init__(self, fn: Callable[[], object]) -> None:
|
|
31
|
+
"""Bind the callable; the outcome slots fill when the pump finishes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
fn: the callable executed by the worker pump.
|
|
35
|
+
"""
|
|
36
|
+
self.fn = fn
|
|
37
|
+
self.done = threading.Event()
|
|
38
|
+
self.error: BaseException | None = None
|
|
39
|
+
self.result: object = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PlaywrightWorker:
|
|
43
|
+
"""The dedicated thread where the Playwright sync session lives.
|
|
44
|
+
|
|
45
|
+
The Playwright sync API runs its private asyncio loop on a greenlet fiber
|
|
46
|
+
of the thread that started it, and that thread keeps the loop's running
|
|
47
|
+
marker until the session stops — which breaks any asyncio-driven host
|
|
48
|
+
(IPython, Jupyter) that executed a step in its own thread. The worker
|
|
49
|
+
keeps the fiber and the marker inside its own background thread instead;
|
|
50
|
+
calls still happen strictly sequentially through :meth:`run`.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
_tasks: the queue feeding the pump thread; ``None`` is the stop sentinel.
|
|
54
|
+
_thread: the pump thread; ``None`` before start and after close.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
"""Prepare the task queue; no thread exists yet."""
|
|
59
|
+
self._tasks: queue.SimpleQueue[Callable[[], object] | None] = queue.SimpleQueue()
|
|
60
|
+
self._thread: threading.Thread | None = None
|
|
61
|
+
|
|
62
|
+
def start(self) -> None:
|
|
63
|
+
"""Spawn the pump thread; it serves queued tasks until close."""
|
|
64
|
+
thread = threading.Thread(target=self._pump, name="prettyplay-playwright", daemon=True)
|
|
65
|
+
thread.start()
|
|
66
|
+
self._thread = thread
|
|
67
|
+
|
|
68
|
+
def run(self, fn: Callable[[], _T]) -> _T:
|
|
69
|
+
"""Execute one callable in the driver thread and block for its outcome.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
fn: the callable to execute; it must touch the Playwright objects
|
|
73
|
+
owned by the worker thread only.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Whatever ``fn`` returns.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
Error: the worker is not running (stopped or never started) — the
|
|
80
|
+
same message Playwright itself raises on a stopped driver.
|
|
81
|
+
"""
|
|
82
|
+
if self._thread is None:
|
|
83
|
+
raise Error("Event loop is closed! Is Playwright already stopped?")
|
|
84
|
+
|
|
85
|
+
task = _Task(fn)
|
|
86
|
+
self._tasks.put(task)
|
|
87
|
+
|
|
88
|
+
task.done.wait()
|
|
89
|
+
|
|
90
|
+
if task.error is not None:
|
|
91
|
+
raise task.error
|
|
92
|
+
|
|
93
|
+
return cast(_T, task.result)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
"""Stop the pump thread and wait for its exit.
|
|
97
|
+
|
|
98
|
+
Idempotent: a call before start and repeated calls are no-ops.
|
|
99
|
+
"""
|
|
100
|
+
thread = self._thread
|
|
101
|
+
if thread is None:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
self._tasks.put(None)
|
|
105
|
+
thread.join()
|
|
106
|
+
self._thread = None
|
|
107
|
+
|
|
108
|
+
def _pump(self) -> None:
|
|
109
|
+
"""Serve queued tasks one by one until the stop sentinel arrives."""
|
|
110
|
+
while True:
|
|
111
|
+
task = self._tasks.get()
|
|
112
|
+
if task is None:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
task.result = task.fn()
|
|
117
|
+
except BaseException as error: # exception propagates to the caller's thread
|
|
118
|
+
task.error = error
|
|
119
|
+
finally:
|
|
120
|
+
task.done.set()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DriverSession:
|
|
124
|
+
"""Owns the Playwright sync driver and one browser process for one test.
|
|
125
|
+
|
|
126
|
+
The constructor starts nothing: the driver and the browser start lazily on
|
|
127
|
+
the first :meth:`open_context` call and then serve every step of the test.
|
|
128
|
+
Every call opens a fresh isolated browser context wrapped into a
|
|
129
|
+
:class:`PageFacade` — no state is shared between tests through the library.
|
|
130
|
+
The whole Playwright session lives inside the thread of a
|
|
131
|
+
:class:`PlaywrightWorker`, so the thread executing the steps never keeps a
|
|
132
|
+
running asyncio loop.
|
|
133
|
+
|
|
134
|
+
Attributes:
|
|
135
|
+
_config: project settings; ``browser`` selects the engine of the matrix,
|
|
136
|
+
``browser_endpoint`` switches the start to a remote ws connect.
|
|
137
|
+
_playwright: the started Playwright driver; ``None`` until first start.
|
|
138
|
+
_browser: the connected or launched browser process; ``None`` until first start.
|
|
139
|
+
_worker: the thread owning the Playwright session; ``None`` until first start.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, config: Config) -> None:
|
|
143
|
+
"""Keep the config; nothing is started yet.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
config: project settings; the browser setting selects the engine.
|
|
147
|
+
"""
|
|
148
|
+
self._config = config
|
|
149
|
+
self._playwright: Playwright | None = None
|
|
150
|
+
self._browser: Browser | None = None
|
|
151
|
+
self._worker: PlaywrightWorker | None = None
|
|
152
|
+
|
|
153
|
+
def open_context(self) -> PageFacade:
|
|
154
|
+
"""Open a fresh isolated context with one page wrapped into the facade.
|
|
155
|
+
|
|
156
|
+
The driver and the browser start lazily on the first call exactly once
|
|
157
|
+
per test; each subsequent call only creates a new isolated context.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The facade of the new page of a fresh isolated context.
|
|
161
|
+
"""
|
|
162
|
+
if self._browser is None:
|
|
163
|
+
self._launch()
|
|
164
|
+
|
|
165
|
+
worker = self._worker
|
|
166
|
+
browser = self._browser
|
|
167
|
+
|
|
168
|
+
def open_isolated() -> tuple[Page, BrowserContext]:
|
|
169
|
+
context: BrowserContext = browser.new_context()
|
|
170
|
+
return context.new_page(), context
|
|
171
|
+
|
|
172
|
+
page, context = worker.run(open_isolated)
|
|
173
|
+
|
|
174
|
+
facade = PageFacade(page, context)
|
|
175
|
+
facade._worker = worker # facade calls go to the driver thread
|
|
176
|
+
return facade
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
"""Stop the browser and the Playwright driver; safe when nothing was started.
|
|
180
|
+
|
|
181
|
+
Idempotent: repeated calls and a call before any launch are no-ops.
|
|
182
|
+
A failing browser close (e.g. after a crash) never skips the
|
|
183
|
+
Playwright stop and the worker thread join — its error still
|
|
184
|
+
propagates after both ran.
|
|
185
|
+
"""
|
|
186
|
+
worker = self._worker
|
|
187
|
+
if worker is None:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
if self._browser is not None:
|
|
192
|
+
worker.run(self._browser.close)
|
|
193
|
+
finally:
|
|
194
|
+
self._browser = None
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
if self._playwright is not None:
|
|
198
|
+
worker.run(self._playwright.stop)
|
|
199
|
+
finally:
|
|
200
|
+
self._playwright = None
|
|
201
|
+
worker.close()
|
|
202
|
+
self._worker = None
|
|
203
|
+
|
|
204
|
+
def _launch(self) -> None:
|
|
205
|
+
"""Start the driver thread, the Playwright session and the browser.
|
|
206
|
+
|
|
207
|
+
Everything Playwright-touching runs inside the worker thread, so the
|
|
208
|
+
caller's thread never adopts the Playwright event loop. On a launch or
|
|
209
|
+
connect failure the driver is stopped and the worker closed before the
|
|
210
|
+
error propagates, so a retry starts from a clean state.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
Exception: whatever the engine start raises.
|
|
214
|
+
"""
|
|
215
|
+
worker = PlaywrightWorker()
|
|
216
|
+
worker.start()
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
playwright = worker.run(lambda: sync_playwright().start())
|
|
220
|
+
except BaseException:
|
|
221
|
+
worker.close()
|
|
222
|
+
raise
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
browser = worker.run(lambda: self._launch_engine(playwright))
|
|
226
|
+
except BaseException:
|
|
227
|
+
# a browser that failed to start leaves no driver process alive
|
|
228
|
+
worker.run(playwright.stop)
|
|
229
|
+
worker.close()
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
self._worker = worker
|
|
233
|
+
self._playwright = playwright
|
|
234
|
+
self._browser = browser
|
|
235
|
+
|
|
236
|
+
def _launch_engine(self, playwright: Playwright) -> Browser:
|
|
237
|
+
"""Start the browser engine selected by the configuration.
|
|
238
|
+
|
|
239
|
+
A set ``browser_endpoint`` connects over the Playwright ws endpoint of
|
|
240
|
+
the selected engine instead of launching locally: the browser setting
|
|
241
|
+
selects the engine type (``chrome``/``msedge`` map to chromium —
|
|
242
|
+
channels do not apply to a connect) and ``headless`` is ignored, because
|
|
243
|
+
window visibility belongs to the endpoint server. Playwright's raw
|
|
244
|
+
connect error carries only the OS-level cause and never the endpoint
|
|
245
|
+
URL, so a failed connect is re-raised wrapped, chained to the original.
|
|
246
|
+
|
|
247
|
+
Otherwise every engine launches with the ``headless`` setting;
|
|
248
|
+
``chrome``/``msedge`` name a locally installed browser launched through
|
|
249
|
+
the chromium engine with the matching channel. A channel launch without
|
|
250
|
+
the installed browser fails with Playwright's own actionable error,
|
|
251
|
+
propagated as-is.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
playwright: the started Playwright session of the worker thread.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The connected or launched browser process of the test.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
Error: a connect failure, wrapped with the endpoint in the message.
|
|
261
|
+
"""
|
|
262
|
+
name = self._config.browser
|
|
263
|
+
|
|
264
|
+
engines: dict[str, object] = {
|
|
265
|
+
"chromium": playwright.chromium,
|
|
266
|
+
"firefox": playwright.firefox,
|
|
267
|
+
"webkit": playwright.webkit,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if self._config.browser_endpoint:
|
|
271
|
+
endpoint = self._config.browser_endpoint
|
|
272
|
+
engine = playwright.chromium if name in ("chrome", "msedge") else engines[name]
|
|
273
|
+
try:
|
|
274
|
+
return cast(Browser, engine.connect(endpoint))
|
|
275
|
+
except Error as failure:
|
|
276
|
+
raise Error(f"cannot connect to the browser endpoint {endpoint}: {failure}") from failure
|
|
277
|
+
|
|
278
|
+
channel: str | None
|
|
279
|
+
if name in ("chrome", "msedge"):
|
|
280
|
+
engine = playwright.chromium
|
|
281
|
+
channel = name
|
|
282
|
+
else:
|
|
283
|
+
engine = engines[name]
|
|
284
|
+
channel = None
|
|
285
|
+
|
|
286
|
+
if channel:
|
|
287
|
+
return cast(Browser, engine.launch(headless=self._config.headless, channel=channel))
|
|
288
|
+
return cast(Browser, engine.launch(headless=self._config.headless))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Step generation
|
|
2
|
+
|
|
3
|
+
Domain: generating executable code for an unknown step. Audience: library internals and engineers debugging a first run.
|
|
4
|
+
|
|
5
|
+
## Generate a step
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
step = generator.generate(
|
|
9
|
+
identity=identity,
|
|
10
|
+
step_text="click the «Sign in» button",
|
|
11
|
+
previous_steps=["open the login page", "enter the login and password"],
|
|
12
|
+
page=page,
|
|
13
|
+
)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- The loop: request code → execute against the live page → on failure re-request with the fresh error and snapshot
|
|
17
|
+
- A non-empty generation_prompt setting adds a USER INSTRUCTIONS block to every generation and regeneration request — the project's code style guidance (e.g. prefer data-test-id attributes); classification requests never carry it; changing the instructions never invalidates the cache — cached steps run as stored
|
|
18
|
+
- A failed check of a candidate (an assertion that executed and did not hold) stops the retries at once: the failure goes to classification — product_defect raises ProductDefectError with the verdict, anything else raises IncurableStepError with it; when the LLM is unavailable at this classification the verdict is skipped quietly (WARNING in the log) and IncurableStepError raises without it; one failed check is spent, never the whole budget
|
|
19
|
+
- Other candidate failures (element not found, timeouts) retry with the fresh error and snapshot
|
|
20
|
+
- Attempts are budgeted per step per test (default 3); exhaustion raises IncurableStepError carrying the classification verdict of the last candidate — when the LLM is unavailable the verdict is skipped quietly (WARNING in the log) and the failure raises without it
|
|
21
|
+
- A success stores the step in the cache and returns it
|
|
22
|
+
- Provider unavailability of a generation request raises LlmUnavailableError immediately — no retry on it
|
|
23
|
+
|
|
24
|
+
## Classification call
|
|
25
|
+
|
|
26
|
+
Both engines classify through one routine:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from prettyplay.engine import classify_step_failure
|
|
30
|
+
|
|
31
|
+
classification = classify_step_failure(
|
|
32
|
+
config=config,
|
|
33
|
+
provider=provider,
|
|
34
|
+
step_text="click the «Sign in» button",
|
|
35
|
+
code=step_code,
|
|
36
|
+
error="element not found: button «Sign in»",
|
|
37
|
+
page=page,
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The routine collects the fresh page snapshot (plus the screenshot when enabled) and calls the provider with the engine classification prompt. Provider unavailability propagates: the calling path decides whether it is a terminal infrastructure failure or a quiet verdict skip.
|
|
42
|
+
|
|
43
|
+
## The fixed form
|
|
44
|
+
|
|
45
|
+
Generated code is one function receiving exactly one argument — the page facade — and working only through the facade surface: page.find_by_role(...).click(), page.find_by_attribute("data-test-id", "submit").click(), page.find_by_css("form > button.primary"), page.find_by_xpath("//button[@type='submit']"), element.expect_visible(), page.scroll_down(600) and alike. No provider constructs, no direct driver imports, no fixed delays.
|