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
prettyplay/scenario.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""The integrator-facing object: one PrettyPlay per test owns the addressing and the page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import types
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
from playwright.sync_api import Page
|
|
13
|
+
|
|
14
|
+
from .cache import StepCache
|
|
15
|
+
from .config import PrettyConfig, load_config
|
|
16
|
+
from .driver import PageFacade
|
|
17
|
+
from .engine import StepGenerator, StepHealer
|
|
18
|
+
from .engine.groups import GroupRecovery
|
|
19
|
+
from .engine.steering import StepSteering
|
|
20
|
+
from .executor import StepExecutor
|
|
21
|
+
from .failures import PrettyplayError
|
|
22
|
+
from .groups import StepGroup, _validate_delay, _validate_tries, _validate_vars
|
|
23
|
+
from .reporting import StepHooks, StepReporter
|
|
24
|
+
from .runtime import PrettyplayRuntime
|
|
25
|
+
|
|
26
|
+
_T = TypeVar("_T")
|
|
27
|
+
|
|
28
|
+
#: The inclusive pace range, in percent, a group's ``speed`` parameter accepts.
|
|
29
|
+
_SPEED_RANGE = range(0, 101)
|
|
30
|
+
|
|
31
|
+
#: The header of the bulky page-state section Playwright appends to a failed
|
|
32
|
+
#: expectation message — column zero; the section runs to the message end.
|
|
33
|
+
_ARIA_SNAPSHOT_HEADER = re.compile(r"^Aria snapshot:[ \t]*$", re.MULTILINE)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _trim_aria_snapshot(error: BaseException) -> None:
|
|
37
|
+
"""Trim the bulky aria-snapshot section from a chained exception's message.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
error: the chained exception; a single-string message carrying the
|
|
41
|
+
page-state section loses it, every other argument shape stays
|
|
42
|
+
untouched.
|
|
43
|
+
"""
|
|
44
|
+
if len(error.args) != 1 or not isinstance(error.args[0], str):
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
text = error.args[0]
|
|
48
|
+
header = _ARIA_SNAPSHOT_HEADER.search(text)
|
|
49
|
+
|
|
50
|
+
if header is not None:
|
|
51
|
+
error.args = (text[: header.start()].rstrip(),)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _raise_folded(error: PrettyplayError) -> types.NoReturn:
|
|
55
|
+
"""Re-raise a library failure with its traceback folded to this boundary.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
error: the library failure leaving ``step``/``expect``.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
Always: the given error with the folded traceback attached — the
|
|
62
|
+
same object, never a copy; only the facade boundary frame stays,
|
|
63
|
+
the chained tracebacks fold away too.
|
|
64
|
+
"""
|
|
65
|
+
tb = error.__traceback__
|
|
66
|
+
_fold_chain_tracebacks(error)
|
|
67
|
+
|
|
68
|
+
folded = types.TracebackType(
|
|
69
|
+
tb_next=None,
|
|
70
|
+
tb_frame=tb.tb_frame,
|
|
71
|
+
tb_lasti=tb.tb_lasti,
|
|
72
|
+
tb_lineno=tb.tb_lineno,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
raise error.with_traceback(folded)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fold_chain_tracebacks(error: BaseException) -> None:
|
|
79
|
+
"""Drop the tracebacks of the chained exceptions, keeping the chains themselves.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
error: the exception whose ``__context__``/``__cause__`` chains are
|
|
83
|
+
folded — every chained single-string message also loses its
|
|
84
|
+
bulky aria-snapshot section, so the page state never floods
|
|
85
|
+
the runner output.
|
|
86
|
+
"""
|
|
87
|
+
pending = [error]
|
|
88
|
+
seen: set[int] = set()
|
|
89
|
+
|
|
90
|
+
while pending:
|
|
91
|
+
current = pending.pop()
|
|
92
|
+
|
|
93
|
+
if id(current) in seen:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
seen.add(id(current))
|
|
97
|
+
_trim_aria_snapshot(current)
|
|
98
|
+
|
|
99
|
+
for link in (current.__context__, current.__cause__):
|
|
100
|
+
if link is not None:
|
|
101
|
+
link.__traceback__ = None
|
|
102
|
+
pending.append(link)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_group_speed(speed: int | None) -> None:
|
|
106
|
+
"""Validate the declared group pace; ``None`` — no between-step pauses.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
speed: the group pace — an integer 0-100 inclusive, bools never
|
|
110
|
+
coerced.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
PrettyplayError: the value is a bool, not an integer or outside
|
|
114
|
+
0-100 — the loud failure names the parameter, the received
|
|
115
|
+
value and the allowed form.
|
|
116
|
+
"""
|
|
117
|
+
if speed is None:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if isinstance(speed, bool) or not isinstance(speed, int) or speed not in _SPEED_RANGE:
|
|
121
|
+
raise PrettyplayError(f"invalid speed {speed!r} — an integer 0-100 inclusive, keyword-only")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PrettyPlay:
|
|
125
|
+
"""The main object of the integrator: one instance per UI test.
|
|
126
|
+
|
|
127
|
+
Attributes:
|
|
128
|
+
_runtime: the composition root owned by this one test — no
|
|
129
|
+
process-wide state: two tests hold two runtimes, two attempt
|
|
130
|
+
registries and two browser sessions.
|
|
131
|
+
_reporter: the visibility point of this test.
|
|
132
|
+
_cache: the step cache of this test.
|
|
133
|
+
_generator: the generation engine of this test.
|
|
134
|
+
_healer: the healing engine of this test.
|
|
135
|
+
_steering: the interactive steering of this test; shares the
|
|
136
|
+
provider, the cache and the reporter of the healer — one
|
|
137
|
+
visibility point and one write-back store per test.
|
|
138
|
+
_recovery: the group recovery engine of this test; shares the
|
|
139
|
+
provider, the cache, the budgets and the reporter of the
|
|
140
|
+
generator — the recovery row regenerates through it.
|
|
141
|
+
_executor: the owner of the step cycle of this test.
|
|
142
|
+
_page: the isolated page of this test; ``None`` until the first step.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
cache_key: str,
|
|
148
|
+
cache_path: str | None = None,
|
|
149
|
+
*,
|
|
150
|
+
hooks: list[StepHooks] | None = None,
|
|
151
|
+
config: PrettyConfig | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Compose the per-test objects over the runtime this test owns; the page and the SDK client stay lazy.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
cache_key: the context key of the test; the addressing part of
|
|
157
|
+
every cached step of this scenario.
|
|
158
|
+
cache_path: the optional subdirectory inside the cache; part of
|
|
159
|
+
the address, so steps of different subdirectories never
|
|
160
|
+
collide.
|
|
161
|
+
hooks: the optional integrator callbacks of this test,
|
|
162
|
+
keyword-only; the reporter is seeded with them at
|
|
163
|
+
construction, so every event of every step reaches them.
|
|
164
|
+
``None`` — an empty hooks list; :meth:`add_hooks` appends
|
|
165
|
+
later.
|
|
166
|
+
config: the programmatic settings layer; explicitly set values
|
|
167
|
+
win over the pyproject+env file layer, unset and empty
|
|
168
|
+
fields resolve from it. ``None`` resolves everything from
|
|
169
|
+
the file layer, as before.
|
|
170
|
+
"""
|
|
171
|
+
effective = load_config(None, config)
|
|
172
|
+
self._runtime = PrettyplayRuntime(effective)
|
|
173
|
+
self._reporter = StepReporter(hooks=hooks or [])
|
|
174
|
+
self._cache = StepCache(self._runtime.config, cache_path, self._reporter)
|
|
175
|
+
|
|
176
|
+
self._generator = StepGenerator(
|
|
177
|
+
self._runtime.config,
|
|
178
|
+
self._runtime.provider,
|
|
179
|
+
self._cache,
|
|
180
|
+
self._runtime.budgets,
|
|
181
|
+
self._reporter,
|
|
182
|
+
)
|
|
183
|
+
self._healer = StepHealer(
|
|
184
|
+
self._runtime.config,
|
|
185
|
+
self._runtime.provider,
|
|
186
|
+
self._generator,
|
|
187
|
+
self._cache,
|
|
188
|
+
self._runtime.budgets,
|
|
189
|
+
self._reporter,
|
|
190
|
+
)
|
|
191
|
+
self._steering = StepSteering(
|
|
192
|
+
self._runtime.config,
|
|
193
|
+
self._runtime.provider,
|
|
194
|
+
self._cache,
|
|
195
|
+
self._reporter,
|
|
196
|
+
)
|
|
197
|
+
self._recovery = GroupRecovery(
|
|
198
|
+
self._runtime.config,
|
|
199
|
+
self._runtime.provider,
|
|
200
|
+
self._generator,
|
|
201
|
+
self._cache,
|
|
202
|
+
self._runtime.budgets,
|
|
203
|
+
self._reporter,
|
|
204
|
+
)
|
|
205
|
+
self._executor = StepExecutor(
|
|
206
|
+
cache_key,
|
|
207
|
+
self._cache,
|
|
208
|
+
self._generator,
|
|
209
|
+
self._healer,
|
|
210
|
+
self._steering,
|
|
211
|
+
self._recovery,
|
|
212
|
+
self._runtime.budgets,
|
|
213
|
+
self._reporter,
|
|
214
|
+
config=self._runtime.config,
|
|
215
|
+
provider=self._runtime.provider, # cheap object construction; the SDK client stays lazy
|
|
216
|
+
)
|
|
217
|
+
self._page: PageFacade | None = None
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def cache_key(self) -> str:
|
|
221
|
+
"""The context key of this test, for diagnostics."""
|
|
222
|
+
return self._executor.cache_key
|
|
223
|
+
|
|
224
|
+
def _ensure_page(self) -> PageFacade:
|
|
225
|
+
"""Open the isolated page of this test lazily, exactly once.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The page facade of this test.
|
|
229
|
+
"""
|
|
230
|
+
if self._page is None:
|
|
231
|
+
self._page = self._runtime.open_page()
|
|
232
|
+
return self._page
|
|
233
|
+
|
|
234
|
+
def step(
|
|
235
|
+
self,
|
|
236
|
+
text: str,
|
|
237
|
+
*,
|
|
238
|
+
tries: int | None = None,
|
|
239
|
+
delay: float | None = None,
|
|
240
|
+
vars: dict[str, str] | None = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Execute one action step sentence through the step cycle.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
text: the sentence of the action as written by the engineer.
|
|
246
|
+
tries: keyword-only — the total number of executions of the
|
|
247
|
+
step's code, the first execution included; a positive
|
|
248
|
+
integer. ``None`` — the time-bounded settle mode of the
|
|
249
|
+
polling settings. An invalid value raises the loud
|
|
250
|
+
actionable failure at the call, before any page or LLM
|
|
251
|
+
involvement.
|
|
252
|
+
delay: keyword-only — the quiet pre-step pause in seconds,
|
|
253
|
+
non-negative, fractional allowed; ``None`` — no pause.
|
|
254
|
+
vars: keyword-only — the call-local string inputs of the step;
|
|
255
|
+
every value must be a string, otherwise the loud actionable
|
|
256
|
+
failure names the parameter, the received value and the
|
|
257
|
+
allowed form. ``None`` — no inputs; a separate namespace
|
|
258
|
+
from memory — ``{{ vars.name }}`` reads these values,
|
|
259
|
+
``{{ name }}`` reads memory; call inputs never establish
|
|
260
|
+
memory.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid —
|
|
264
|
+
the loud failure names the parameter, the received value
|
|
265
|
+
and the allowed form; every library failure leaving this
|
|
266
|
+
method carries its traceback folded to this boundary.
|
|
267
|
+
ProductDefectError: the step expectation is genuinely broken in the product.
|
|
268
|
+
IncurableStepError: the step never generated successfully, or the verdict
|
|
269
|
+
says regeneration cannot help.
|
|
270
|
+
LLMUnavailableError: the provider service failed; no retry.
|
|
271
|
+
ComplianceVerdictError: the compliance gate could not obtain a
|
|
272
|
+
usable verdict; the executed candidate is never cached.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
_validate_tries(tries)
|
|
276
|
+
_validate_delay(delay)
|
|
277
|
+
_validate_vars(vars)
|
|
278
|
+
self._executor.execute(text, "action", self._ensure_page(), tries=tries, delay=delay, vars=vars)
|
|
279
|
+
except PrettyplayError as error:
|
|
280
|
+
_raise_folded(error)
|
|
281
|
+
|
|
282
|
+
def expect(
|
|
283
|
+
self,
|
|
284
|
+
text: str,
|
|
285
|
+
*,
|
|
286
|
+
tries: int | None = None,
|
|
287
|
+
delay: float | None = None,
|
|
288
|
+
vars: dict[str, str] | None = None,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Execute one assertion step sentence through the step cycle.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
text: the sentence of the assertion as written by the engineer.
|
|
294
|
+
tries: keyword-only — the total number of executions of the
|
|
295
|
+
step's code, the first execution included; a positive
|
|
296
|
+
integer. ``None`` — the time-bounded settle mode of the
|
|
297
|
+
polling settings. An invalid value raises the loud
|
|
298
|
+
actionable failure at the call, before any page or LLM
|
|
299
|
+
involvement.
|
|
300
|
+
delay: keyword-only — the quiet pre-step pause in seconds,
|
|
301
|
+
non-negative, fractional allowed; ``None`` — no pause.
|
|
302
|
+
vars: keyword-only — the call-local string inputs of the step,
|
|
303
|
+
validated exactly as :meth:`step`; ``None`` — no inputs.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
PrettyplayError: ``tries``, ``delay`` or ``vars`` is invalid;
|
|
307
|
+
the traceback folding is exactly as :meth:`step`.
|
|
308
|
+
ProductDefectError: the step expectation is genuinely broken in the product.
|
|
309
|
+
IncurableStepError: the step never generated successfully, or the verdict
|
|
310
|
+
says regeneration cannot help.
|
|
311
|
+
LLMUnavailableError: the provider service failed; no retry.
|
|
312
|
+
ComplianceVerdictError: the compliance gate could not obtain a
|
|
313
|
+
usable verdict; the executed candidate is never cached.
|
|
314
|
+
"""
|
|
315
|
+
try:
|
|
316
|
+
_validate_tries(tries)
|
|
317
|
+
_validate_delay(delay)
|
|
318
|
+
_validate_vars(vars)
|
|
319
|
+
self._executor.execute(text, "assertion", self._ensure_page(), tries=tries, delay=delay, vars=vars)
|
|
320
|
+
except PrettyplayError as error:
|
|
321
|
+
_raise_folded(error)
|
|
322
|
+
|
|
323
|
+
def group(self, prompt: str, *, speed: int | None = None, delay: float | None = None) -> StepGroup:
|
|
324
|
+
"""Open the group authoring block — one coherent mini-scenario with a shared goal.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
prompt: the group prompt, verbatim — the shared goal of the
|
|
328
|
+
block, reaching the generation and diagnosis requests;
|
|
329
|
+
empty raises the loud actionable failure at entry.
|
|
330
|
+
speed: keyword-only — the group pace, an integer 0-100; the
|
|
331
|
+
pauses between the group's steps follow the same
|
|
332
|
+
percent-to-ms mapping as the browser speed setting.
|
|
333
|
+
``None`` — no between-step pauses.
|
|
334
|
+
delay: keyword-only — the quiet pause before the group's first
|
|
335
|
+
step, seconds, non-negative; ``None`` — no pause.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
The authoring group object — yield it from a ``with`` block: it
|
|
339
|
+
carries the ordinary step surface and frames the block with the
|
|
340
|
+
group lifecycle events; group membership changes no step's cache
|
|
341
|
+
address.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
PrettyplayError: ``prompt`` is empty, ``speed`` is malformed or
|
|
345
|
+
outside 0-100, or ``delay`` is negative or malformed.
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
if not prompt:
|
|
349
|
+
raise PrettyplayError(
|
|
350
|
+
"the group prompt must not be empty — a group block needs its shared goal sentence"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
_validate_group_speed(speed)
|
|
354
|
+
_validate_delay(delay)
|
|
355
|
+
|
|
356
|
+
block = StepGroup(prompt, speed, delay, self._executor, self._reporter)
|
|
357
|
+
block._open_page = self._ensure_page # the lazy opener of this test; the constructor stays five-parameter
|
|
358
|
+
|
|
359
|
+
return block
|
|
360
|
+
except PrettyplayError as error:
|
|
361
|
+
_raise_folded(error)
|
|
362
|
+
|
|
363
|
+
def run_on_page(self, action: Callable[[Page], _T]) -> _T:
|
|
364
|
+
"""Execute the author action wholly inside the driver worker thread.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
action: the callable to execute; receives the genuine sync Page,
|
|
368
|
+
runs sequentially with every step — the stateful actions
|
|
369
|
+
excluded from generated code are the author's explicit tools
|
|
370
|
+
here.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The outcome of ``action`` as-is — plain data only; Playwright
|
|
374
|
+
objects never cross back to the calling thread.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
PrettyplayError: no test page exists yet — run a step first.
|
|
378
|
+
Exception: whatever ``action`` raises propagates to the caller
|
|
379
|
+
as-is; a call back into the test object from inside the
|
|
380
|
+
action is rejected with a loud error instead of a deadlock.
|
|
381
|
+
"""
|
|
382
|
+
if self._page is None:
|
|
383
|
+
raise PrettyplayError("no test page yet: run a step first — the page opens lazily on the first step")
|
|
384
|
+
|
|
385
|
+
return self._page.run(action)
|
|
386
|
+
|
|
387
|
+
def get_screenshot(self) -> bytes:
|
|
388
|
+
"""Return a full-page PNG screenshot of the current test page.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
The PNG image bytes of the page — the decision to capture
|
|
392
|
+
belongs to the author: nothing is captured automatically on
|
|
393
|
+
step failures.
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
PrettyplayError: no test page exists yet — run a step first.
|
|
397
|
+
"""
|
|
398
|
+
if self._page is None:
|
|
399
|
+
raise PrettyplayError("no test page yet: run a step first — the page opens lazily on the first step")
|
|
400
|
+
|
|
401
|
+
return self._page.screenshot()
|
|
402
|
+
|
|
403
|
+
def save_screenshot(self, filepath: str) -> None:
|
|
404
|
+
"""Save a full-page PNG screenshot of the current test page to a file.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
filepath: the destination path of the PNG file.
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
PrettyplayError: no test page exists yet, or the file cannot be
|
|
411
|
+
written — parent directories are never created silently, a
|
|
412
|
+
missing directory is a loud failure; the original
|
|
413
|
+
``OSError`` is chained.
|
|
414
|
+
"""
|
|
415
|
+
if self._page is None:
|
|
416
|
+
raise PrettyplayError("no test page yet: run a step first — the page opens lazily on the first step")
|
|
417
|
+
|
|
418
|
+
image = self._page.screenshot()
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
Path(filepath).write_bytes(image)
|
|
422
|
+
except OSError as error:
|
|
423
|
+
raise PrettyplayError(f"cannot write the screenshot to {filepath}: {error}") from error
|
|
424
|
+
|
|
425
|
+
def add_hooks(self, hooks: StepHooks) -> None:
|
|
426
|
+
"""Register integrator hooks for the events of this test.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
hooks: the hooks object; register before the first step to see
|
|
430
|
+
every event of the scenario.
|
|
431
|
+
"""
|
|
432
|
+
self._reporter.hooks.append(hooks)
|
|
433
|
+
|
|
434
|
+
def close(self) -> None:
|
|
435
|
+
"""Close the page, then unconditionally the whole runtime of this test; idempotent."""
|
|
436
|
+
page = self._page
|
|
437
|
+
self._page = None
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
if page is not None:
|
|
441
|
+
page.close()
|
|
442
|
+
finally:
|
|
443
|
+
self._runtime.close()
|
|
444
|
+
|
|
445
|
+
def __enter__(self) -> PrettyPlay:
|
|
446
|
+
"""Enter the scenario block of one test.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
This test object.
|
|
450
|
+
"""
|
|
451
|
+
return self
|
|
452
|
+
|
|
453
|
+
def __exit__(
|
|
454
|
+
self,
|
|
455
|
+
exc_type: type[BaseException] | None,
|
|
456
|
+
exc_value: BaseException | None,
|
|
457
|
+
traceback: TracebackType | None,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Leave the scenario block: close the test and never suppress.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
exc_type: the type of the block exception, if any.
|
|
463
|
+
exc_value: the block exception, if any.
|
|
464
|
+
traceback: the traceback of the block exception, if any.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Nothing — a falsy return keeps the exception propagating.
|
|
468
|
+
"""
|
|
469
|
+
self.close()
|