Two things that don't behave like a normal page

Most of what a page does fits neatly into "load some HTML, maybe run some JavaScript, render it." Native dialogs and file downloads both break that pattern in their own way:

  • A dialog (alert(), confirm(), prompt()) isn't part of the page's HTML at all, it's the browser itself, pausing the entire page until a human clicks a button. Left alone, a headless script will simply hang forever, since there's no human there to click anything.
  • A download doesn't render as a page, the browser hands a file to the operating system instead of showing content. Your script needs a different mechanism to notice one happened and grab the result.

The code, piece by piece

def handle_dialog(dialog: Dialog) -> None:
nonlocal captured_message
captured_message = dialog.message
dialog.accept()
page.on("dialog", handle_dialog)

page.on("dialog", handle_dialog) registers a callback that fires automatically the instant a native dialog appears, no matter what triggered it. dialog.message is the text inside it; .accept() clicks "OK" (for a confirm() dialog, .dismiss() would click "Cancel" instead). The nonlocal keyword lets handle_dialog, a function defined inside another function, write to a variable from the enclosing function's scope rather than creating its own new local one.

The handler has to be registered before the click that opens the dialog. Dialogs happen synchronously from the browser's point of view, if there's no handler listening yet, Playwright's default behavior (dismissing it) kicks in instead of yours.

with page.expect_download() as download_info:
page.locator("a[href='download/sample.txt']").click()
download = download_info.value

Same pattern as context.expect_page() from Lesson 11: start listening for the download before the click that triggers it, inside the with block, so there's no window where a fast download could complete before you started watching for it.

This particular page lists every file visitors of this public practice site have uploaded through its companion /upload page, so most of the list changes constantly and some entries are empty test files. Targeting sample.txt by name, one of the site's own permanent fixtures, keeps this lesson downloading the same real file on every run instead of whatever a stranger uploaded five minutes ago.

saved_path = OUTPUT_DIR / download.suggested_filename
download.save_as(saved_path)

The moment download exists, the file is sitting in a temporary location Playwright manages. .suggested_filename is the name the server intended for it (from its Content-Disposition header); .save_as() copies it to a path you actually control, here inside this lesson's own output/ folder.

Checkpoint

  • Native dialog: alert()/confirm()/prompt(), generated by the browser itself, not the page's HTML; pauses everything until answered, must be handled via page.on("dialog", ...) or a headless script will hang.
  • Register before you trigger: both page.on("dialog", ...) and page.expect_download() need to be set up before the action that causes the event, to avoid missing it in a race.
  • dialog.accept() / dialog.dismiss(): click "OK" or "Cancel" on a dialog programmatically.
  • download.save_as(): copies a triggered download from its temporary location to a path you choose.

If anything here still feels unclear, ask before moving to Lesson 15.