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.

Files changed (58) hide show
  1. prettyplay/.usages/lifecycle.md +50 -0
  2. prettyplay/.usages/steps.md +56 -0
  3. prettyplay/CODEMANIFEST +182 -0
  4. prettyplay/__init__.py +13 -0
  5. prettyplay/cache/.usages/addressing.md +31 -0
  6. prettyplay/cache/.usages/budgets.md +21 -0
  7. prettyplay/cache/.usages/storage.md +32 -0
  8. prettyplay/cache/CODEMANIFEST +152 -0
  9. prettyplay/cache/__init__.py +8 -0
  10. prettyplay/cache/budgets.py +73 -0
  11. prettyplay/cache/models.py +60 -0
  12. prettyplay/cache/store.py +260 -0
  13. prettyplay/cache/text.py +35 -0
  14. prettyplay/config/.usages/configuration.md +87 -0
  15. prettyplay/config/CODEMANIFEST +132 -0
  16. prettyplay/config/__init__.py +6 -0
  17. prettyplay/config/loader.py +192 -0
  18. prettyplay/config/models.py +92 -0
  19. prettyplay/driver/.usages/facade.md +66 -0
  20. prettyplay/driver/CODEMANIFEST +162 -0
  21. prettyplay/driver/__init__.py +6 -0
  22. prettyplay/driver/page.py +350 -0
  23. prettyplay/driver/session.py +288 -0
  24. prettyplay/engine/.usages/generation.md +45 -0
  25. prettyplay/engine/.usages/healing.md +31 -0
  26. prettyplay/engine/CODEMANIFEST +215 -0
  27. prettyplay/engine/__init__.py +8 -0
  28. prettyplay/engine/classification.py +69 -0
  29. prettyplay/engine/execution.py +25 -0
  30. prettyplay/engine/generator.py +318 -0
  31. prettyplay/engine/healer.py +116 -0
  32. prettyplay/engine/text.py +19 -0
  33. prettyplay/executor.py +109 -0
  34. prettyplay/failures/.usages/taxonomy.md +40 -0
  35. prettyplay/failures/CODEMANIFEST +117 -0
  36. prettyplay/failures/__init__.py +17 -0
  37. prettyplay/failures/errors.py +147 -0
  38. prettyplay/llm/.usages/classification.md +25 -0
  39. prettyplay/llm/.usages/providers.md +33 -0
  40. prettyplay/llm/CODEMANIFEST +125 -0
  41. prettyplay/llm/__init__.py +8 -0
  42. prettyplay/llm/_request.py +216 -0
  43. prettyplay/llm/anthropic_provider.py +213 -0
  44. prettyplay/llm/models.py +22 -0
  45. prettyplay/llm/openai_provider.py +187 -0
  46. prettyplay/llm/provider.py +115 -0
  47. prettyplay/reporting/.usages/hooks.md +41 -0
  48. prettyplay/reporting/CODEMANIFEST +79 -0
  49. prettyplay/reporting/__init__.py +6 -0
  50. prettyplay/reporting/hooks.py +37 -0
  51. prettyplay/reporting/reporter.py +70 -0
  52. prettyplay/runtime.py +107 -0
  53. prettyplay/scenario.py +291 -0
  54. prettyplay-0.0.0.dist-info/METADATA +236 -0
  55. prettyplay-0.0.0.dist-info/RECORD +58 -0
  56. prettyplay-0.0.0.dist-info/WHEEL +5 -0
  57. prettyplay-0.0.0.dist-info/licenses/LICENSE +28 -0
  58. 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.