prettyplay 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. prettyplay/.usages/lifecycle.md +117 -0
  2. prettyplay/.usages/steps.md +169 -0
  3. prettyplay/__init__.py +18 -0
  4. prettyplay/cache/.usages/addressing.md +44 -0
  5. prettyplay/cache/.usages/budgets.md +51 -0
  6. prettyplay/cache/.usages/storage.md +32 -0
  7. prettyplay/cache/__init__.py +8 -0
  8. prettyplay/cache/budgets.py +93 -0
  9. prettyplay/cache/models.py +55 -0
  10. prettyplay/cache/store.py +276 -0
  11. prettyplay/cache/text.py +36 -0
  12. prettyplay/config/.usages/configuration.md +246 -0
  13. prettyplay/config/__init__.py +6 -0
  14. prettyplay/config/loader.py +336 -0
  15. prettyplay/config/models.py +288 -0
  16. prettyplay/driver/.usages/error_kinds.md +26 -0
  17. prettyplay/driver/.usages/plumbing.md +52 -0
  18. prettyplay/driver/__init__.py +11 -0
  19. prettyplay/driver/errors.py +76 -0
  20. prettyplay/driver/page.py +175 -0
  21. prettyplay/driver/session.py +330 -0
  22. prettyplay/engine/.usages/generation.md +157 -0
  23. prettyplay/engine/.usages/healing.md +61 -0
  24. prettyplay/engine/__init__.py +19 -0
  25. prettyplay/engine/attempts.py +80 -0
  26. prettyplay/engine/classification.py +70 -0
  27. prettyplay/engine/compliance.py +125 -0
  28. prettyplay/engine/execution.py +48 -0
  29. prettyplay/engine/generator.py +1110 -0
  30. prettyplay/engine/groups/.usages/recovery.md +71 -0
  31. prettyplay/engine/groups/__init__.py +7 -0
  32. prettyplay/engine/groups/diagnosis.py +128 -0
  33. prettyplay/engine/groups/outcome.py +55 -0
  34. prettyplay/engine/groups/recovery.py +491 -0
  35. prettyplay/engine/healer.py +149 -0
  36. prettyplay/engine/polling/.usages/settle.md +55 -0
  37. prettyplay/engine/polling/__init__.py +6 -0
  38. prettyplay/engine/polling/settle.py +98 -0
  39. prettyplay/engine/polling/window.py +72 -0
  40. prettyplay/engine/renderer/.usages/memory.md +24 -0
  41. prettyplay/engine/renderer/.usages/rendering.md +44 -0
  42. prettyplay/engine/renderer/.usages/validation.md +20 -0
  43. prettyplay/engine/renderer/__init__.py +7 -0
  44. prettyplay/engine/renderer/memory.py +37 -0
  45. prettyplay/engine/renderer/render.py +184 -0
  46. prettyplay/engine/renderer/validation.py +76 -0
  47. prettyplay/engine/steering/.usages/steering.md +95 -0
  48. prettyplay/engine/steering/__init__.py +5 -0
  49. prettyplay/engine/steering/steering.py +765 -0
  50. prettyplay/engine/text.py +41 -0
  51. prettyplay/executor.py +654 -0
  52. prettyplay/failures/.usages/taxonomy.md +131 -0
  53. prettyplay/failures/__init__.py +25 -0
  54. prettyplay/failures/errors.py +359 -0
  55. prettyplay/groups.py +276 -0
  56. prettyplay/llm/.usages/classification.md +31 -0
  57. prettyplay/llm/.usages/providers.md +127 -0
  58. prettyplay/llm/.usages/transport.md +81 -0
  59. prettyplay/llm/__init__.py +38 -0
  60. prettyplay/llm/_request.py +823 -0
  61. prettyplay/llm/anthropic_provider.py +468 -0
  62. prettyplay/llm/models.py +273 -0
  63. prettyplay/llm/openai_provider.py +444 -0
  64. prettyplay/llm/provider.py +279 -0
  65. prettyplay/reporting/.usages/hooks.md +61 -0
  66. prettyplay/reporting/__init__.py +6 -0
  67. prettyplay/reporting/hooks.py +47 -0
  68. prettyplay/reporting/reporter.py +70 -0
  69. prettyplay/runtime.py +81 -0
  70. prettyplay/scenario.py +469 -0
  71. prettyplay-0.0.2.dist-info/METADATA +515 -0
  72. prettyplay-0.0.2.dist-info/RECORD +75 -0
  73. prettyplay-0.0.2.dist-info/WHEEL +5 -0
  74. prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
  75. prettyplay-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,52 @@
1
+ # Driver plumbing — the internal page handle
2
+
3
+ Domain: the worker-thread boundary and the internal page handle of prettyplay. Audience: implementers of the engine,
4
+ steering and root cells — the code that executes step code, collects page state and offers the author escape hatch.
5
+ Not an API for generated step code: generated code receives the genuine Playwright sync Page inside the worker thread.
6
+
7
+ ## The worker boundary
8
+
9
+ The whole Playwright session lives in one dedicated driver thread. The only crossing point is the run primitive of the
10
+ page handle: a callable executes wholly inside the worker thread and receives the genuine sync Page there.
11
+
12
+ result = page.run(action) # action(page) runs inside the worker thread
13
+
14
+ Immediate reads such as `page.url` cross the boundary the same way — one unit through the
15
+ run primitive, plain string back; the calling thread never adopts the Playwright event loop.
16
+
17
+ - The calling thread never adopts the Playwright event loop — the IPython/Jupyter guarantee holds
18
+ - The result returns as-is; an exception propagates as-is: an AssertionError of a step reaches failure classification
19
+ untouched
20
+ - A re-entrant crossing is rejected loudly: a callable calling back into the worker (a step, a screenshot, a nested
21
+ run from inside a run_on_page action) raises an Error instead of deadlocking both threads
22
+ - Playwright objects never cross back: the callable returns plain data
23
+
24
+ ## The handle members
25
+
26
+ | Call | Purpose |
27
+ |---|---|
28
+ | page.run(action) | execute the callable inside the worker thread with the genuine sync Page |
29
+ | page.url | the current page URL — an immediate read through the worker-thread run primitive |
30
+ | page.aria_snapshot() | the accessibility-tree page state — the primary LLM input |
31
+ | page.screenshot() | full-page PNG bytes |
32
+ | page.close() | close this page's isolated context |
33
+
34
+ ## Step execution through the boundary
35
+
36
+ Step code of the fixed form `def step(page)` receives the genuine sync Page. Compile and resolve stay on the calling
37
+ thread; the whole step call runs inside the worker as one run. Every standard Playwright call — locators, actions,
38
+ `expect` chains, plain asserts on immediate reads, `locator.count()` — is the real thing on the real object.
39
+
40
+ ## Dialogs — resolver of last resort
41
+
42
+ The runtime registers one dialog routing handler per page before any step code runs. A dialog handled by an in-step
43
+ stock capture (`with page.expect_event("dialog") as info:` ... `info.value.accept()`) is never touched by the router.
44
+ Every unclaimed dialog is resolved exactly once: accept when accept_dialogs is on, an explicit dismiss otherwise.
45
+ The step wins, the router is last — deterministic inside the worker thread. The drain of one unit is bounded: a page
46
+ that fires a fresh dialog for every resolution leaves the excess pending for the next unit tail — no unit runs forever.
47
+
48
+ ## Rules
49
+
50
+ - One browser process per test; contexts stay isolated
51
+ - Driving is strictly sequential: one run at a time
52
+ - No fixed delays anywhere in runtime plumbing — waits live in locators and expectations of step code
@@ -0,0 +1,11 @@
1
+ """Facade of the prettyplay.driver cell: the Playwright sync driver of prettyplay."""
2
+
3
+ from .errors import is_pollable_failure
4
+ from .page import PageFacade
5
+ from .session import DriverSession
6
+
7
+ __all__ = [
8
+ "DriverSession",
9
+ "PageFacade",
10
+ "is_pollable_failure",
11
+ ]
@@ -0,0 +1,76 @@
1
+ """The fixed pollable map of the prettyplay.driver error surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from playwright.sync_api import Error as PlaywrightError
8
+
9
+ #: The timeout signature of the installed Playwright driver — "Timeout NNNms
10
+ #: exceeded" over locator, action, expectation and navigation waits alike.
11
+ _TIMEOUT_PATTERN = re.compile(r"timeout \d+m?s exceeded")
12
+
13
+ #: Fixed message-pattern set of the pollable driver kinds — element state,
14
+ #: detach/stale handles, navigation and context races. Every pattern matches
15
+ #: a real message of the installed Playwright driver; the detach family is
16
+ #: exactly this set.
17
+ _POLLABLE_PATTERNS = (
18
+ "element is not visible",
19
+ "element is not enabled",
20
+ "element is outside of the viewport",
21
+ "element is not attached",
22
+ "frame has been detached",
23
+ "detached from document",
24
+ "execution context was destroyed",
25
+ "target closed",
26
+ "interrupted by another navigation",
27
+ "navigation interrupted the evaluation",
28
+ )
29
+
30
+
31
+ def _matches_pollable(text: str) -> bool:
32
+ """Match a Playwright error text against the fixed pollable pattern set.
33
+
34
+ Args:
35
+ text: the message text of the exception, any casing.
36
+
37
+ Returns:
38
+ True when the lowercased text carries a timeout signature or one of
39
+ the fixed pollable message patterns.
40
+ """
41
+ lower = text.lower()
42
+
43
+ if _TIMEOUT_PATTERN.search(lower):
44
+ return True
45
+
46
+ return any(pattern in lower for pattern in _POLLABLE_PATTERNS)
47
+
48
+
49
+ def is_pollable_failure(exc: Exception) -> bool:
50
+ """Decide whether a failed step-code exception is transient page state.
51
+
52
+ Args:
53
+ exc: the exception raised by the failed step code.
54
+
55
+ Returns:
56
+ True when the kind is transient — the settle window may re-execute
57
+ the same code; False — deterministic or unknown, it goes straight
58
+ to classification. True covers a failed check and the
59
+ timeout/element-state/navigation driver families; False covers
60
+ locator ambiguity and Python-level errors.
61
+ """
62
+ text = str(exc)
63
+
64
+ # Locator ambiguity first — the elements are there, waiting will not
65
+ # collapse them to one; an ambiguity-styled AssertionError is refused too.
66
+ if "strict mode violation" in text:
67
+ return False
68
+
69
+ # A failed expectation: the check executed and did not hold; the state
70
+ # may catch up.
71
+ if isinstance(exc, AssertionError):
72
+ return True
73
+
74
+ # Driver kinds by type and message pattern — never pattern alone; anything
75
+ # else — Python-level or unknown — is the conservative default.
76
+ return isinstance(exc, PlaywrightError) and _matches_pollable(text)
@@ -0,0 +1,175 @@
1
+ """The internal runtime plumbing handle of a single test page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, TypeVar
8
+
9
+ from playwright.sync_api import BrowserContext, Dialog, Page
10
+
11
+ if TYPE_CHECKING:
12
+ from .session import PlaywrightWorker
13
+
14
+ _T = TypeVar("_T")
15
+
16
+ logger = logging.getLogger("prettyplay")
17
+
18
+ # The drain bound of one resolver pass: a page that fires a fresh dialog for
19
+ # every resolution would keep the pass — and with it the whole driver-thread
20
+ # unit — alive forever; the excess rolls forward to the next unit tail instead.
21
+ _RESOLVE_PASS_LIMIT = 128
22
+
23
+
24
+ class PageFacade:
25
+ """The internal runtime plumbing handle of a single test page — plumbing only, no proxies of page capabilities.
26
+
27
+ Attributes:
28
+ _page: the wrapped Playwright page; never exposed to the calling thread.
29
+ _context: the isolated context owning the page; the handle boundary.
30
+ _worker: the driver thread of the owning session; ``None`` for
31
+ hand-built handles, which then call Playwright inline.
32
+ _router: the dialog routing state shared by every page of the context;
33
+ ``None`` for a handle without a session — no handler is registered,
34
+ hence nothing is ever pending.
35
+ """
36
+
37
+ def __init__(self, page: Page, context: BrowserContext) -> None:
38
+ """Wrap one Playwright page of an isolated context.
39
+
40
+ Args:
41
+ page: the Playwright page object; never exposed to the calling thread.
42
+ context: the isolated context of the page; the handle boundary.
43
+ """
44
+ self._page = page
45
+ self._context = context
46
+ self._worker: PlaywrightWorker | None = None
47
+ self._router: _DialogRouter | None = None
48
+
49
+ @property
50
+ def url(self) -> str:
51
+ """The current URL of the page — an immediate read.
52
+
53
+ Returns:
54
+ The current URL of the page, read as one driver-thread unit; a
55
+ plain string crosses back, no Playwright object.
56
+ """
57
+ return self._call(lambda: self._page.url)
58
+
59
+ def run(self, action: Callable[[Page], _T]) -> _T:
60
+ """Execute the callable wholly inside the driver worker thread — the only crossing point of the boundary.
61
+
62
+ Args:
63
+ action: the callable to execute; receives the genuine sync Page.
64
+
65
+ Returns:
66
+ Whatever ``action`` returns — plain data only; Playwright
67
+ objects never cross back. The leftover-dialog pass runs after
68
+ the action — it never raises and never masks the outcome; an
69
+ exception inside the action propagates untouched.
70
+ """
71
+
72
+ return self._call(lambda: action(self._page))
73
+
74
+ def _resolve_leftovers(self) -> None:
75
+ """Run the deferred dialog pass of the driver-thread unit; never raises."""
76
+ if self._router is not None:
77
+ self._router.resolve_pending()
78
+
79
+ def _call(self, fn: Callable[[], _T]) -> _T:
80
+ """Run one callable in the driver thread with the leftover pass.
81
+
82
+ Args:
83
+ fn: the callable touching the wrapped Playwright objects.
84
+
85
+ Returns:
86
+ Whatever ``fn`` returns — the deferred dialog pass drains every
87
+ unclaimed dialog after the callable, so no pending dialog
88
+ survives the boundary.
89
+ """
90
+
91
+ def unit() -> _T:
92
+ """Run fn and resolve the dialog leftovers afterwards."""
93
+ try:
94
+ return fn()
95
+ finally:
96
+ self._resolve_leftovers()
97
+
98
+ if self._worker is None: # hand-built handle (tests): inline execution
99
+ return unit()
100
+
101
+ return self._worker.run(unit) # one queued unit; outcome/error re-raised as-is
102
+
103
+ def aria_snapshot(self) -> str:
104
+ """Capture the accessibility-tree state of the page.
105
+
106
+ Returns:
107
+ The aria snapshot of the page body.
108
+ """
109
+ return self._call(lambda: self._page.locator("body").aria_snapshot())
110
+
111
+ def screenshot(self) -> bytes:
112
+ """Capture a full-page screenshot.
113
+
114
+ Returns:
115
+ The PNG image of the whole page as bytes.
116
+ """
117
+ return self._call(lambda: self._page.screenshot(full_page=True))
118
+
119
+ def close(self) -> None:
120
+ """Close the isolated context of the page; the test's browser keeps running."""
121
+ self._call(self._context.close)
122
+
123
+
124
+ class _DialogRouter:
125
+ """The single dialog routing state of one browser context — the resolver of last resort at every unit tail.
126
+
127
+ Attributes:
128
+ accept_dialogs: the ``browser.accept_dialogs`` setting read once at
129
+ context creation; ``True`` accepts unclaimed dialogs — the
130
+ router is the resolver of last resort at every unit tail:
131
+ accept on this setting, else an explicit dismiss.
132
+ _pending: the dialogs recorded by the routing handlers and not yet
133
+ resolved; only the single worker thread touches it.
134
+ """
135
+
136
+ def __init__(self, accept_dialogs: bool) -> None:
137
+ """Keep the setting; nothing is pending yet.
138
+
139
+ Args:
140
+ accept_dialogs: whether dialogs no in-step stock dialog capture
141
+ claims are accepted automatically at the unit tail.
142
+ """
143
+ self.accept_dialogs = accept_dialogs
144
+ self._pending: list[Dialog] = []
145
+
146
+ def record(self, dialog: Dialog) -> None:
147
+ """Record a dialog fired on any page of the context — pure bookkeeping.
148
+
149
+ Args:
150
+ dialog: the raw Playwright dialog fired on the page; no
151
+ Playwright call happens inside the event dispatch, so the
152
+ step's own in-step capture stays free to claim it.
153
+ """
154
+ self._pending.append(dialog)
155
+
156
+ def resolve_pending(self) -> None:
157
+ """Resolve recorded dialogs — the bounded tail pass of every driver-thread unit; never raises."""
158
+ resolved = 0
159
+ while self._pending and resolved < _RESOLVE_PASS_LIMIT:
160
+ dialog = self._pending.pop(0) # FIFO — the unstarted tail stays ordered for the next unit tail
161
+ resolved += 1
162
+ try:
163
+ if self.accept_dialogs:
164
+ dialog.accept()
165
+ else:
166
+ dialog.dismiss()
167
+ except Exception as failure:
168
+ if "already handled" in str(failure):
169
+ continue # the step's capture resolved it — never double-handle
170
+ logger.warning("dialog resolution failed", extra={"error": str(failure)})
171
+ if self._pending: # the drain cap held the unit open — the rest waits for the next unit tail
172
+ logger.warning(
173
+ "dialog drain limit reached; the rest resolves at the next unit tail",
174
+ extra={"pending": len(self._pending), "limit": _RESOLVE_PASS_LIMIT},
175
+ )
@@ -0,0 +1,330 @@
1
+ """Lifecycle owner of the Playwright sync driver and the browser process of one test."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import queue
7
+ import re
8
+ import threading
9
+ from collections.abc import Callable
10
+ from typing import TypeVar, cast
11
+
12
+ from playwright.sync_api import Browser, BrowserContext, Error, Page, Playwright, sync_playwright
13
+
14
+ from ..config import Config
15
+ from .page import PageFacade, _DialogRouter
16
+
17
+ _T = TypeVar("_T")
18
+
19
+
20
+ class _Task:
21
+ """One unit of work crossing from the caller thread into the driver thread.
22
+
23
+ Attributes:
24
+ fn: the callable executed by the worker pump.
25
+ done: set once fn returned or raised in the worker thread.
26
+ result: the return value of fn; ``None`` until done.
27
+ error: the exception fn raised, if any.
28
+ """
29
+
30
+ __slots__ = ("done", "error", "fn", "result")
31
+
32
+ def __init__(self, fn: Callable[[], object]) -> None:
33
+ """Bind the callable; the outcome slots fill when the pump finishes.
34
+
35
+ Args:
36
+ fn: the callable executed by the worker pump.
37
+ """
38
+ self.fn = fn
39
+ self.done = threading.Event()
40
+ self.error: BaseException | None = None
41
+ self.result: object = None
42
+
43
+
44
+ class PlaywrightWorker:
45
+ """The dedicated thread where the Playwright sync session lives — keeping the asyncio marker off the caller thread.
46
+
47
+ Attributes:
48
+ _tasks: the queue feeding the pump thread; ``None`` is the stop sentinel.
49
+ _thread: the pump thread; ``None`` before start and after close.
50
+ """
51
+
52
+ def __init__(self) -> None:
53
+ """Prepare the task queue; no thread exists yet."""
54
+ self._tasks: queue.SimpleQueue[Callable[[], object] | None] = queue.SimpleQueue()
55
+ self._thread: threading.Thread | None = None
56
+
57
+ def start(self) -> None:
58
+ """Spawn the pump thread; it serves queued tasks until close."""
59
+ thread = threading.Thread(target=self._pump, name="prettyplay-playwright", daemon=True)
60
+ thread.start()
61
+ self._thread = thread
62
+
63
+ def run(self, fn: Callable[[], _T]) -> _T:
64
+ """Execute one callable in the driver thread and block for its outcome.
65
+
66
+ Args:
67
+ fn: the callable to execute; it must touch the Playwright objects
68
+ owned by the worker thread only.
69
+
70
+ Returns:
71
+ Whatever ``fn`` returns.
72
+
73
+ Raises:
74
+ Error: the worker is not running (stopped or never started) — the
75
+ same message Playwright itself raises on a stopped driver; or
76
+ the call arrives from the worker's own pump thread — code
77
+ running inside a driver-thread unit (a ``run_on_page``
78
+ action, step code) crossed the boundary again, a unit the
79
+ busy pump could never serve; the loud error replaces the
80
+ silent deadlock of both threads.
81
+ """
82
+ if self._thread is None:
83
+ raise Error("Event loop is closed! Is Playwright already stopped?")
84
+
85
+ if threading.current_thread() is self._thread:
86
+ raise Error(
87
+ "re-entrant crossing of the driver thread: code running inside a "
88
+ "driver-thread unit called back into the worker — an action inside "
89
+ "run_on_page (or step code) must use the genuine Page directly, "
90
+ "never the prettyplay test object, whose every call marshals into "
91
+ "the same worker and could never be served"
92
+ )
93
+
94
+ task = _Task(fn)
95
+ self._tasks.put(task)
96
+
97
+ task.done.wait()
98
+
99
+ if task.error is not None:
100
+ raise task.error
101
+
102
+ return cast(_T, task.result)
103
+
104
+ def close(self) -> None:
105
+ """Stop the pump thread and wait for its exit; idempotent."""
106
+ thread = self._thread
107
+ if thread is None:
108
+ return
109
+
110
+ self._tasks.put(None)
111
+ thread.join()
112
+ self._thread = None
113
+
114
+ def _pump(self) -> None:
115
+ """Serve queued tasks one by one until the stop sentinel arrives."""
116
+ while True:
117
+ task = self._tasks.get()
118
+ if task is None:
119
+ return
120
+
121
+ try:
122
+ task.result = task.fn()
123
+ except BaseException as error: # exception propagates to the caller's thread
124
+ task.error = error
125
+ finally:
126
+ task.done.set()
127
+
128
+
129
+ class DriverSession:
130
+ """Owns the Playwright sync driver and one browser process for one test.
131
+
132
+ Attributes:
133
+ _config: project settings; the ``browser`` group selects the engine of
134
+ the matrix, its ``screen`` sets the size mode of every context,
135
+ and its ``endpoint`` switches the start to a remote ws connect.
136
+ _playwright: the started Playwright driver; ``None`` until first start.
137
+ _browser: the connected or launched browser process; ``None`` until first start.
138
+ _worker: the thread owning the Playwright session; ``None`` until first start.
139
+ """
140
+
141
+ def __init__(self, config: Config) -> None:
142
+ """Keep the config; nothing is started yet.
143
+
144
+ Args:
145
+ config: project settings; the ``browser`` group selects the engine.
146
+ """
147
+ self._config = config
148
+ self._playwright: Playwright | None = None
149
+ self._browser: Browser | None = None
150
+ self._worker: PlaywrightWorker | None = None
151
+
152
+ def open_context(self) -> PageFacade:
153
+ """Open a fresh isolated context with one page wrapped into the handle.
154
+
155
+ Returns:
156
+ The page handle of a fresh isolated context — the driver and the
157
+ browser start lazily on the first call; the dialog router is
158
+ registered before ``new_page()``: every page of the context,
159
+ popups included, registers exactly one handler.
160
+
161
+ Raises:
162
+ Error: the ``screen`` value names a device absent from the
163
+ registry of the running Playwright.
164
+ """
165
+ if self._browser is None:
166
+ self._launch()
167
+
168
+ worker = self._worker
169
+ browser = self._browser
170
+
171
+ def open_isolated() -> tuple[Page, BrowserContext, _DialogRouter]:
172
+ """Open one fresh isolated context with one page in the driver thread.
173
+
174
+ Returns:
175
+ The page, its context and the dialog router registered
176
+ before the first page event.
177
+ """
178
+ params = self._screen_context_params()
179
+ context: BrowserContext = browser.new_context(**params)
180
+ router = _DialogRouter(self._config.browser.accept_dialogs)
181
+ # registered before new_page: the first page fires the event and registers once
182
+ context.on("page", lambda opened: opened.on("dialog", router.record))
183
+ page = context.new_page()
184
+ return page, context, router
185
+
186
+ page, context, router = worker.run(open_isolated)
187
+
188
+ facade = PageFacade(page, context)
189
+ facade._worker = worker # handle calls marshal into the driver thread
190
+ facade._router = router # the shared router records every dialog of every page of the context
191
+ return facade
192
+
193
+ def _screen_context_params(self) -> dict[str, object]:
194
+ """Resolve the screen setting of the browser group into context parameters.
195
+
196
+ Returns:
197
+ The ``new_context`` keyword arguments — resolved inside the
198
+ driver thread against the registry of the running Playwright:
199
+ empty keeps the default, ``fullscreen`` follows the window or
200
+ pins a viewport, a WxH value is a fixed viewport, anything else
201
+ is a device name.
202
+
203
+ Raises:
204
+ Error: the value names no device of the running registry; the
205
+ message carries the closest registry names.
206
+ """
207
+ group = self._config.browser
208
+ screen = group.screen
209
+
210
+ if screen == "":
211
+ return {}
212
+
213
+ if screen == "fullscreen":
214
+ if group.endpoint == "" and not group.headless:
215
+ return {"no_viewport": True} # the viewport follows the window
216
+ return {"viewport": {"width": 1920, "height": 1080}} # no window exists there
217
+
218
+ match = re.fullmatch(r"(\d+)x(\d+)", screen)
219
+ if match is not None:
220
+ return {"viewport": {"width": int(match.group(1)), "height": int(match.group(2))}}
221
+
222
+ devices = self._playwright.devices # the registry of the running Playwright
223
+ if screen in devices:
224
+ return dict(devices[screen])
225
+
226
+ close = difflib.get_close_matches(screen, devices, n=3, cutoff=0.5)
227
+ suggestion = f" — closest names: {', '.join(close)}" if close else ""
228
+ raise Error(f"unknown screen device {screen!r}: not in the playwright device registry{suggestion}")
229
+
230
+ def close(self) -> None:
231
+ """Stop the browser, the Playwright driver and the worker; idempotent; a failing close never skips the rest."""
232
+ worker = self._worker
233
+ if worker is None:
234
+ return
235
+
236
+ try:
237
+ if self._browser is not None:
238
+ worker.run(self._browser.close)
239
+ finally:
240
+ self._browser = None
241
+
242
+ try:
243
+ if self._playwright is not None:
244
+ worker.run(self._playwright.stop)
245
+ finally:
246
+ self._playwright = None
247
+ worker.close()
248
+ self._worker = None
249
+
250
+ def _launch(self) -> None:
251
+ """Start the driver thread, the Playwright session and the browser.
252
+
253
+ Raises:
254
+ Exception: whatever the engine start raises — on a launch or
255
+ connect failure the driver is stopped and the worker closed
256
+ before the error propagates, so a retry starts from a clean
257
+ state.
258
+ """
259
+ worker = PlaywrightWorker()
260
+ worker.start()
261
+
262
+ try:
263
+ playwright = worker.run(lambda: sync_playwright().start())
264
+ except BaseException:
265
+ worker.close()
266
+ raise
267
+
268
+ try:
269
+ browser = worker.run(lambda: self._launch_engine(playwright))
270
+ except BaseException:
271
+ # a browser that failed to start leaves no driver process alive
272
+ worker.run(playwright.stop)
273
+ worker.close()
274
+ raise
275
+
276
+ self._worker = worker
277
+ self._playwright = playwright
278
+ self._browser = browser
279
+
280
+ def _launch_engine(self, playwright: Playwright) -> Browser:
281
+ """Start the browser engine selected by the browser group of the configuration.
282
+
283
+ Args:
284
+ playwright: the started Playwright session of the worker thread.
285
+
286
+ Returns:
287
+ The connected or launched browser process — the group ``speed``
288
+ maps to the native ``slow_mo``, fixed at process start and never
289
+ a code wait; a set ``endpoint`` connects instead of launching,
290
+ with ``headless`` ignored; ``chrome``/``msedge`` launch through
291
+ the chromium engine with the matching channel.
292
+
293
+ Raises:
294
+ Error: a connect failure — re-raised wrapped with the endpoint,
295
+ chained to the original.
296
+ """
297
+ group = self._config.browser
298
+ name = group.name
299
+ # the pace setting maps linearly to the native slow_mo: 100 -> 0 ms, 0 -> 3000 ms
300
+ slow_mo = int((100 - group.speed) * 30)
301
+
302
+ engines: dict[str, object] = {
303
+ "chromium": playwright.chromium,
304
+ "firefox": playwright.firefox,
305
+ "webkit": playwright.webkit,
306
+ }
307
+
308
+ if group.endpoint:
309
+ endpoint = group.endpoint
310
+ engine = playwright.chromium if name in ("chrome", "msedge") else engines[name]
311
+ try:
312
+ return cast(Browser, engine.connect(endpoint, slow_mo=slow_mo))
313
+ except Error as failure:
314
+ raise Error(f"cannot connect to the browser endpoint {endpoint}: {failure}") from failure
315
+
316
+ channel: str | None
317
+ if name in ("chrome", "msedge"):
318
+ engine = playwright.chromium
319
+ channel = name
320
+ else:
321
+ engine = engines[name]
322
+ channel = None
323
+
324
+ launch_kwargs: dict[str, object] = {"headless": group.headless, "slow_mo": slow_mo}
325
+ if channel:
326
+ launch_kwargs["channel"] = channel
327
+ if group.screen == "fullscreen" and name in ("chromium", "chrome", "msedge") and not group.headless:
328
+ # a window exists only on a local headed launch — start it maximized
329
+ launch_kwargs["args"] = ["--start-maximized"]
330
+ return cast(Browser, engine.launch(**launch_kwargs))