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.
- prettyplay/.usages/lifecycle.md +117 -0
- prettyplay/.usages/steps.md +169 -0
- prettyplay/__init__.py +18 -0
- prettyplay/cache/.usages/addressing.md +44 -0
- prettyplay/cache/.usages/budgets.md +51 -0
- prettyplay/cache/.usages/storage.md +32 -0
- prettyplay/cache/__init__.py +8 -0
- prettyplay/cache/budgets.py +93 -0
- prettyplay/cache/models.py +55 -0
- prettyplay/cache/store.py +276 -0
- prettyplay/cache/text.py +36 -0
- prettyplay/config/.usages/configuration.md +246 -0
- prettyplay/config/__init__.py +6 -0
- prettyplay/config/loader.py +336 -0
- prettyplay/config/models.py +288 -0
- prettyplay/driver/.usages/error_kinds.md +26 -0
- prettyplay/driver/.usages/plumbing.md +52 -0
- prettyplay/driver/__init__.py +11 -0
- prettyplay/driver/errors.py +76 -0
- prettyplay/driver/page.py +175 -0
- prettyplay/driver/session.py +330 -0
- prettyplay/engine/.usages/generation.md +157 -0
- prettyplay/engine/.usages/healing.md +61 -0
- prettyplay/engine/__init__.py +19 -0
- prettyplay/engine/attempts.py +80 -0
- prettyplay/engine/classification.py +70 -0
- prettyplay/engine/compliance.py +125 -0
- prettyplay/engine/execution.py +48 -0
- prettyplay/engine/generator.py +1110 -0
- prettyplay/engine/groups/.usages/recovery.md +71 -0
- prettyplay/engine/groups/__init__.py +7 -0
- prettyplay/engine/groups/diagnosis.py +128 -0
- prettyplay/engine/groups/outcome.py +55 -0
- prettyplay/engine/groups/recovery.py +491 -0
- prettyplay/engine/healer.py +149 -0
- prettyplay/engine/polling/.usages/settle.md +55 -0
- prettyplay/engine/polling/__init__.py +6 -0
- prettyplay/engine/polling/settle.py +98 -0
- prettyplay/engine/polling/window.py +72 -0
- prettyplay/engine/renderer/.usages/memory.md +24 -0
- prettyplay/engine/renderer/.usages/rendering.md +44 -0
- prettyplay/engine/renderer/.usages/validation.md +20 -0
- prettyplay/engine/renderer/__init__.py +7 -0
- prettyplay/engine/renderer/memory.py +37 -0
- prettyplay/engine/renderer/render.py +184 -0
- prettyplay/engine/renderer/validation.py +76 -0
- prettyplay/engine/steering/.usages/steering.md +95 -0
- prettyplay/engine/steering/__init__.py +5 -0
- prettyplay/engine/steering/steering.py +765 -0
- prettyplay/engine/text.py +41 -0
- prettyplay/executor.py +654 -0
- prettyplay/failures/.usages/taxonomy.md +131 -0
- prettyplay/failures/__init__.py +25 -0
- prettyplay/failures/errors.py +359 -0
- prettyplay/groups.py +276 -0
- prettyplay/llm/.usages/classification.md +31 -0
- prettyplay/llm/.usages/providers.md +127 -0
- prettyplay/llm/.usages/transport.md +81 -0
- prettyplay/llm/__init__.py +38 -0
- prettyplay/llm/_request.py +823 -0
- prettyplay/llm/anthropic_provider.py +468 -0
- prettyplay/llm/models.py +273 -0
- prettyplay/llm/openai_provider.py +444 -0
- prettyplay/llm/provider.py +279 -0
- prettyplay/reporting/.usages/hooks.md +61 -0
- prettyplay/reporting/__init__.py +6 -0
- prettyplay/reporting/hooks.py +47 -0
- prettyplay/reporting/reporter.py +70 -0
- prettyplay/runtime.py +81 -0
- prettyplay/scenario.py +469 -0
- prettyplay-0.0.2.dist-info/METADATA +515 -0
- prettyplay-0.0.2.dist-info/RECORD +75 -0
- prettyplay-0.0.2.dist-info/WHEEL +5 -0
- prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
- 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))
|