Real apps don't live on a single page. They open documentation in a new tab, embed Stripe inside an iframe, fire a window.confirm before deleting a record, and pop OAuth flows into separate windows. Selenium and Cypress historically struggled with all four; Playwright has first-class APIs for each, and Python's sync API makes them readable. This lesson covers the four corner cases that show up on every real test: popup tabs, iframes (same-origin and cross-origin), JavaScript dialogs, and the expect_* context-manager pattern Python uses where TypeScript uses await page.waitForEvent.
New tabs and popups — expect_popup
Click "Help" or "Open in new tab" and a fresh browser tab opens. Playwright surfaces it as a Page event. The Python idiom is the expect_popup context manager:
from playwright.sync_api import Page, expect
def test_help_link_opens_new_tab(page: Page):
page.goto("/")
with page.expect_popup() as popup_info:
page.get_by_role("link", name="Open Help").click()
popup = popup_info.value
popup.wait_for_load_state()
expect(popup).to_have_title("Help — MyApp")
expect(popup.get_by_role("heading")).to_contain_text("FAQ")The with block:
- Registers the listener for the
popupevent before the click. - Triggers the action that opens the new tab.
- Resolves when the new page is created.
- Yields a
popup_infoobject whose.valueis the newPage.
The new page works like any other — goto, get_by_role, expect. When you're done, popup.close() (or just let the context tear down at end of test).
Multiple tabs — context.expect_page
Some apps open new tabs without using target="_blank" on links — window.open() from JavaScript instead. The event fires on the BrowserContext rather than the source page. Use context.expect_page:
def test_external_link_opens_in_new_tab(context, page: Page):
page.goto("/")
with context.expect_page() as new_page_info:
page.get_by_role("link", name="External docs").click()
new_page = new_page_info.value
new_page.wait_for_load_state()
expect(new_page).to_have_url("https://docs.example.com/")page.expect_popup is the page-scoped variant; context.expect_page catches any new page in the context, regardless of which existing page triggered it. When in doubt, use context.expect_page — it's strictly more general.
Iframes — frame_locator
An iframe is a complete document embedded inside the host page. Stripe, Braintree, reCAPTCHA, OAuth dialogs, rich-text editors all use them. To act on elements inside an iframe, descend into it with frame_locator:
def test_stripe_payment_form(page: Page):
page.goto("/checkout")
iframe = page.frame_locator("#stripe-card-iframe")
iframe.get_by_label("Card number").fill("4242 4242 4242 4242")
iframe.get_by_label("Expiry").fill("12/26")
iframe.get_by_label("CVC").fill("123")
iframe.get_by_role("button", name="Pay").click()frame_locator returns a FrameLocator — chainable, scoped to the iframe's document. Every locator method (get_by_role, get_by_label, get_by_text, locator) works on it. Cross-origin iframes (Stripe is on js.stripe.com, not your domain) are no different — Playwright drives them via the same DevTools protocol, no same-origin restrictions like Cypress.
Nested iframes
Some pages embed iframes inside iframes. frame_locator chains:
outer = page.frame_locator("#outer-frame")
inner = outer.frame_locator("#inner-frame")
inner.get_by_role("button", name="Submit").click()Each call returns another FrameLocator scoped one level deeper. Two levels is rare; three is exotic.
When frame_locator doesn't fit — the Frame object
For more dynamic cases — finding a frame by name or URL pattern — drop down to the Frame API:
# By name attribute
frame = page.frame(name="payment-frame")
# By URL pattern
frame = page.frame(url=re.compile(r"/checkout/"))
if frame:
frame.get_by_label("Card number").fill("...")page.frame(...) returns a Frame (not a FrameLocator), which has a slightly different API — most things still work, but the lazy locator chain is replaced with eager queries. Reach for frame_locator first; only drop to frame when you need to identify the frame by something other than a CSS selector.
JavaScript dialogs — alert, confirm, prompt
window.alert, window.confirm, and window.prompt are blocking dialogs the OS draws on top of the browser. Playwright auto-dismisses them by default so they don't hang tests. To handle them yourself, register a listener:
# Accept any alert
page.on("dialog", lambda dialog: dialog.accept())
# Dismiss any confirm
page.on("dialog", lambda dialog: dialog.dismiss())
# Type into a prompt and accept
page.on("dialog", lambda dialog: dialog.accept("My input"))For one-shot handlers (a dialog you expect once), use page.once("dialog", ...). For per-test handlers that need to assert on the message:
def test_delete_confirmation(page: Page):
page.goto("/items/42")
def handle_dialog(dialog):
assert dialog.message == "Are you sure you want to delete this item?"
assert dialog.type == "confirm"
dialog.accept()
page.once("dialog", handle_dialog)
page.get_by_role("button", name="Delete").click()
expect(page).to_have_url("/items")Register the dialog handler before triggering the action that opens it. A handler registered after the click misses the dialog, the dialog auto-dismisses, and your assertion never fires.
Dialog types and methods
The dialog object passed to your handler:
dialog.type—"alert","confirm","prompt", or"beforeunload".dialog.message— the text shown in the dialog.dialog.default_value— the prefilled input for a prompt.dialog.accept(prompt_text=None)— clicks OK; pass text to fill a prompt.dialog.dismiss()— clicks Cancel.
def handle_prompt(dialog):
if dialog.type == "prompt":
dialog.accept("Alice")
else:
dialog.dismiss()
page.on("dialog", handle_prompt)How popups, iframes, and dialogs flow
A complete payment-flow test
Pulling popups, iframes, and dialogs together:
def test_full_checkout_with_payment_iframe(page: Page):
# 1. Add to cart and start checkout
page.goto("/products/laptop")
page.get_by_role("button", name="Add to cart").click()
page.get_by_role("link", name="Checkout").click()
# 2. Fill payment form inside Stripe iframe
stripe = page.frame_locator("#stripe-card-iframe")
stripe.get_by_label("Card number").fill("4242 4242 4242 4242")
stripe.get_by_label("Expiry").fill("12/26")
stripe.get_by_label("CVC").fill("123")
# 3. Confirm dialog before paying
def confirm_amount(dialog):
assert "£99.99" in dialog.message
dialog.accept()
page.once("dialog", confirm_amount)
# 4. Pay — opens a 3DS popup
with page.expect_popup() as threeds_info:
page.get_by_role("button", name="Pay").click()
threeds = threeds_info.value
threeds.get_by_role("button", name="Complete authentication").click()
# 5. Back on main page, verify success
expect(page.get_by_text("Thank you for your order")).to_be_visible()One test exercises iframes, dialogs, and popups — the three corner cases of real-world e-commerce. Each gets its own block; the test reads top-to-bottom like the user flow it represents.
Coming from Playwright TypeScript?
The mappings:
- TS
await page.waitForEvent('popup')→ Pythonwith page.expect_popup() as info: ...; info.value - TS
await context.waitForEvent('page')→ Pythonwith context.expect_page() as info: ...; info.value - TS
page.frameLocator('#x')→ Pythonpage.frame_locator("#x") - TS
page.on('dialog', dialog => dialog.accept())→ Pythonpage.on("dialog", lambda dialog: dialog.accept()) - TS
dialog.accept('text')→ Pythondialog.accept(prompt_text="text")(kwarg)
Behavioural parity. The Python expect_* context manager is arguably cleaner than the TS waitForEvent + Promise.all pattern that's common in TS examples — the with statement makes the lifecycle visually obvious.
⚠️ Common mistakes
- Registering the dialog listener after the action.
page.click(...); page.on("dialog", ...)misses the dialog entirely — Playwright auto-dismissed it before the listener attached. Always register first, click second. - Calling
frame_locatoron a CSS selector that doesn't match an iframe.page.frame_locator("#main")happily returns a FrameLocator even if#mainis a<div>, but every action you do through it will time out because the document never loads. Confirm the element is actually<iframe>first — devtools will show you. - Forgetting
popup.wait_for_load_state()afterexpect_popup. The popupPageexists as soon as the new tab opens, but the document may still be loading. Callingpopup.get_by_role(...)immediately can race the load.popup.wait_for_load_state()waits for theloadevent before you start asserting.
🎯 Practice task
Build tests that exercise all three corner cases. 30 minutes.
-
Pick a public sandbox with iframes — The Internet's iframe page works well.
-
Create
tests/test_iframe.py:from playwright.sync_api import Page, expect def test_iframe_text_editing(page: Page): page.goto("https://the-internet.herokuapp.com/iframe") editor = page.frame_locator("#mce_0_ifr") editor.locator("body").fill("Hello from Playwright!") expect(editor.locator("body")).to_have_text("Hello from Playwright!") -
Run with
pytest tests/test_iframe.py -v. The test enters the TinyMCE iframe and edits the rich-text content. -
Add a popup test. Pick any site that opens links in new tabs (Sauce Demo's footer has a "Sauce Labs" link with
target="_blank") and assert the new tab opens to the expected URL:def test_external_link_opens_new_tab(context, page: Page): page.goto("https://www.saucedemo.com") page.get_by_placeholder("Username").fill("standard_user") page.get_by_placeholder("Password").fill("secret_sauce") page.get_by_role("button", name="Login").click() with context.expect_page() as new_info: page.locator("a[href*='saucelabs']").first.click() new_tab = new_info.value new_tab.wait_for_load_state() assert "saucelabs" in new_tab.url -
Add a dialog test. Find a public page with
window.alertorwindow.confirm. The Internet's JavaScript Alerts page has all three:def test_js_confirm_dialog(page: Page): page.goto("https://the-internet.herokuapp.com/javascript_alerts") def handle(dialog): assert dialog.message == "I am a JS Confirm" dialog.accept() page.once("dialog", handle) page.get_by_role("button", name="Click for JS Confirm").click() expect(page.locator("#result")).to_have_text("You clicked: Ok") -
Force a missing-listener failure. In the dialog test, register the handler after the click. Re-run — Playwright auto-dismissed the dialog before your listener attached, the assertion on
#resultshows "Cancel" instead of "Ok". Move the registration back above the click; the test passes again. This is the single biggest gotcha in dialog handling. -
Stretch: combine two corner cases. On a payment flow demo, fill an iframe form and handle a confirmation dialog before submission. Verify the test reads cleanly top-to-bottom — that's the shape of every real checkout test.
You've covered the chapter 5 advanced patterns end-to-end. The next chapter shifts to visual testing and accessibility — to_have_screenshot, axe-playwright-python, and the reporting that surfaces both kinds of regression to the team.