Writing steps
Authoring UI tests as plain sentences. For engineers writing tests and integrators wiring the library into a test framework.
A test as a scenario
from prettyplay import PrettyPlay
def test_login():
t = PrettyPlay("login-flow")
t.step("open the login page")
t.step("enter the login and password")
t.step("click the «Sign in» button")
t.expect("the «Welcome back» message appears")
t.close()
Or with the context manager:
with PrettyPlay("login-flow") as t:
t.step("open the login page")
Step kinds
step(text)— performs what the sentence saysexpect(text)— verifies what the sentence says; a legitimately failed expectation fails the test as a product defect
Step parameters
step(text, tries=3)— the total number of executions of the step's code, the first execution included; for that step it replaces the time-bounded settle window of the polling settings with a count-bounded one — only pollable failures re-execute, afterpolling_delay, until the count is spent; the global polling settings stay untouched; absorbed retries appear assettle_retryrecords only, the step stays green — see Settle pollingstep(text, delay=1.5)— a quiet pause in seconds before the step's code runs: the started event fires, the declared seconds pass, then the code; a step never reached never pauses- Both parameters are keyword-only and accepted by both step kinds; invalid
values (zero or negative
tries, negativedelay) fail loudly at the call — before any page or LLM involvement
Step groups
with t.group("accept cookies, fill and submit the order form") as g:
g.step("accept the cookie banner")
g.step("fill the email field", delay=0.5)
g.step("submit the form")
g.expect("the status shows order confirmed", tries=2)
The group block is one coherent mini-scenario with a shared goal: inside, steps use the ordinary authoring surface — retry count and start pause included. A failing group step on a non-strict run is diagnosed with the whole group in view and the affected row is recovered automatically; group membership changes no step's cache address. See Groups.
Stateful page actions — the escape hatch
Some actions stay out of generated code because they outlive a step on the
page shared by the whole test: network interception (page.route), the
clock (page.clock), add_init_script, tracing, HAR, CDP. They belong to
the author, executed explicitly through run_on_page:
with PrettyPlay("login-flow") as t:
t.step("open the login page")
def stub_the_api(page): # runs inside the driver worker thread, on the genuine page
page.route("**/api/config", lambda route: route.fulfill(json={"mode": "demo"}))
t.run_on_page(stub_the_api)
t.step("the dashboard renders in demo mode")
- The action receives the genuine sync
Pageand runs sequentially with every step — the same primitive step code crosses through (see Driver facade) - The action must use the page API only — calling back into the test
object (
t.step,t.get_screenshot, a nestedt.run_on_page) marshals into the same worker thread the action runs on; the worker rejects the re-entrant crossing with a loud error instead of a deadlock - Requires an opened page: call it after the first step — before that a
loud
PrettyplayErrorraises - The outcome returns as-is; an exception inside the action propagates untouched
Screenshots
Two author-facing abilities on the test object:
with PrettyPlay("login-flow") as t:
t.step("open the login page")
png = t.get_screenshot() # full-page PNG bytes of the current state
t.save_screenshot("artifacts/home.png") # write full-page PNG to an explicit path
- Both require an opened page: call them after the first step of the test
- Nothing is captured automatically on failures — attaching screenshots to reports is the author's decision
What the generated code looks like
The step sentence stays a plain sentence; the generated code is standard Playwright sync API running on the genuine page — see Driver facade:
from playwright.sync_api import expect
# «open the login page and sign in» — an action step ends at its action
page.goto("https://example.com/login")
page.get_by_label("Username").fill("user")
page.get_by_role("button", name="Sign in").click()
# «the page shows a list of videos» — count forms, one check per meaning
items = page.get_by_role("listitem")
assert items.count() > 1
# «click «Delete» and accept the confirmation dialog»
with page.expect_event("dialog") as info:
page.get_by_role("button", name="Delete").click()
info.value.accept()
# «click «Open docs» — the documentation opens in a new tab»
with page.expect_popup() as popup_info:
page.get_by_role("link", name="Open docs").click()
popup = popup_info.value
popup.bring_to_front()
# «click «Pay» inside the embedded checkout frame»
checkout = page.frame_locator("#checkout")
checkout.get_by_role("button", name="Pay").click()
# «scroll down until the footer shows»
page.mouse.wheel(0, 600)
expect(page.get_by_text("Footer")).to_be_visible()
Dialogs that no in-step capture claims are resolved by the driver's
resolver of last resort — accepted when the accept_dialogs browser
setting is on, explicitly dismissed when off — see
Configuration.
Addressing
The constructor arguments form the cache address: cache_key (mandatory) and
cache_path (optional subdirectory). Equal cache keys in the shared root
reuse one cached step across tests; a different language, step type or key is
a different step. User instructions (generation_prompt,
classification_prompt) take no part in the address — a cached step never
regenerates because the instructions changed. See
Step cache.
How a step is generated
On a cache miss the generation engine produces a candidate and executes it against the live page:
- The loop: request code → execute against the live page → on failure re-request with the grown attempt history and the fresh snapshot
- Attempts are budgeted per step per test (default 3); exhaustion raises
IncurableStepErrorcarrying the classification verdict of the last candidate - A failed check of a candidate (an assertion that executed and did not hold)
stops the retries at once: the failure goes through the bounded-healing
decision table —
product_defectraisesProductDefectErrorwith the verdict,incurableraisesIncurableStepErrorwith it, and arotorfixableverdict grants exactly one healing-funded regeneration carrying the classification recommendation; a repeat failure of that regeneration is terminal with one final classification deciding the kind; one failed check is spent, never the whole budget - Other candidate failures (element not found, timeouts) retry with the fresh error and snapshot
- Only a success is stored in the cache — and only after it passes the
compliance gate (
generation_approve, default on), which judges both the user instructions and the step adequacy — whether the code accomplishes what the step sentence says for its step type: ahighfinding in either dimension fails the attempt and the retry carries the violation with the grown history,mediumandlowfindings pass with aWARNING, a malformed verdict is a loudComplianceVerdictError. See Configuration - Provider unavailability of a generation request raises
LLMUnavailableErrorimmediately — no retry on it
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), binding for the generated code while
the compliance gate is on; classification requests never carry
it. The instructions are not part of the cache address: changing them never
invalidates cached steps — replayed code is never re-gated, so changed
instructions need a manual cache purge to take effect.
What you see
Step sentences go to the logger prettyplay at info level — the suite output
reads as a plain-language scenario. A failed step renders one structured
message — the primary reason, the step and the full underlying error, the
verdict — identical in the runner output, the log and the on_step_failed
hook. Healing, cache writes and skipped writes are reported loudly through the
same logger.
Limitations
!!! warning Step sentences land in the repository cache, the logs and the LLM requests: never put secrets or personal data into a step.