Register before the triggering action, then await an ordinary value:

use Fluffy.Assert
import Fluffy
import Fluffy.Locator
alias Fluffy.{Dialog, Download, Event, FileChooser, Frame, Page}

pending = wait_for(session, Event.download())
click(session, by_role(:button, name: "Download report"))
download = await(pending)
assert download.suggested_filename == "report.csv"
assert Download.read!(download) =~ "customer_id,total"

Each wait has an independent source, predicate, and deadline. The deadline starts at registration, using the session timeout unless timeout: is supplied. Await once; reuse the returned value freely. Separate waits can observe the same event. A settled event outcome survives subsequent timeout or source closure. A still-pending wait reports timeout, page closure, or session closure through await.

Constructors accept an optional unary predicate. False and nil skip an event; predicate failures propagate through await. Keep predicates short and inspect available metadata. Use ordinary assertions when failure details matter.

Pipeline expectations

Fluffy.Assert.assert_event/3 and Fluffy.Expect.expect_event/3 register before invoking the action, await a matching event, optionally inspect it, and return the input session:

session
|> assert_event(
  Event.download(),
  &click(&1, by_role(:button, name: "Download report")),
  fn download ->
    assert download.suggested_filename == "report.csv"
    assert Download.read!(download) =~ "customer_id,total"
  end
)
|> click(by_role(:button, name: "Continue"))

The assertion callback and options are optional. With four arguments, pass either an assertion callback or options. Both callbacks run once in the caller process; their return values are ignored. Actions update shared state, while the enclosing pipeline retains its original page selection. The helper releases its registration on every exit and preserves callback errors.

Downloads

A download handle exposes suggested_filename and url when downloading starts. Download.read!(download, max_bytes: 10_000_000) waits for completion and returns bytes, enforcing the indicated in-memory limit (10 MB by default). Download.save_as!(download, destination) waits and saves using bounded memory. Failures raise when reading or saving. Handles belong to their session; saved files outlive it. In-process HTTP downloads use the same API. JavaScript-generated downloads require Playwright.

Pages, popups, and frames

A page is a browser tab or window. A popup is a page opened by another page; ordinary target="_blank" tabs count. Each browser context owns a session's cookies and storage. Frames are documents inside a page, including its main frame and any child iframes.

Event.popup() observes pages opened by the selected page at registration. Event.page() observes any new page in the context. Both may return handles for the same physical page. The event resolves immediately; the page may still be loading. Subsequent actions and assertions handle readiness.

main = current_page(session)
pending = wait_for(session, Event.popup())
click(session, by_role(:link, name: "Open report"))
report = await(pending)

session
|> switch_page(report)
|> assert(page_opener(main))
|> assert(page_url(path: "/reports/preview"))
|> close_page()
|> switch_page(main)

Inspect live page handles with Fluffy.Page.url/1, Fluffy.Page.status/1, and Fluffy.Page.opener/1. The opener is nil if absent or closed. Fluffy.Playwright.current_page/1 returns a stable handle that survives navigation; Fluffy.Playwright.pages/1 lists all open pages, including tabs discovered without an event registration. Page selection is local to each session handle: retain the result of Fluffy.Playwright.switch_page/2; other session handles keep their own selection. Closed page handles are invalid; closing a page does not change any session handle's selection. Explicitly switch to an open page before continuing. Closing an opener leaves its child pages alive.

Fluffy.Playwright.new_page(session) returns a new blank page handle without changing the session's selection. Pass that handle to Fluffy.Playwright.switch_page/2 or Fluffy.Playwright.close_page/2:

Import Fluffy.Playwright alongside Fluffy to use browser-only helpers without module qualifiers.

import Fluffy.Playwright

main = current_page(session)
dashboard = new_page(session)

session
|> switch_page(dashboard)
|> visit("/dashboard")
|> close_page(dashboard)
|> switch_page(main)

Pages share cookies and storage; start another session for an independent user. Pages are identified only by handles: new_page/2, page_names/1, Page.name/1, and atom names in switching, closing, and opener assertions have been removed. Retain current_page(session) to return to the initial page, and use pages(session) to list open handles. Phoenix follows links and submits forms in the current page, ignoring target and formtarget; page creation and closing require Playwright.

Frame locators query iframe contents without switching pages. Browser-only Event.frame_navigated() observes navigation of any frame belonging to the page bound at registration, including frames created later. It returns a live frame handle: Fluffy.Frame.url/1, Fluffy.Frame.page/1, and Fluffy.Frame.parent_frame/1 expose current metadata. The main frame has no parent. Navigation events signal frame navigation, not loading completion.

For navigation outcomes on either backend, use ordinary page assertions:

session
|> click(by_role(:link, name: "Continue"))
|> assert(page_url(path: "/next"))
|> assert(page_status(200))

Fluffy.Playwright.go_back/2 and Fluffy.Playwright.go_forward/2 require Playwright and accept timeout:. For reloading either backend, see Reloading. Fluffy.close_session/1 releases session resources on either backend.

Listeners and dialogs

on(session, event, handler) registers a persistent listener. once removes its registration before invoking the first matching handler; it does not require an event to occur. All three listener operations return the input session. off(session, event, handler) removes only the most recent registration matching the source, event type, and handler; the predicate does not affect removal. Repeated registrations require repeated removal calls.

Each listener handles matching events in delivery order. Different listeners run independently; relative ordering is unspecified. Handlers and predicates execute outside the session runtime and browser connection, linked to the registering caller so unhandled failures fail that caller. Registration completes before returning. Listeners have no deadline and are cleaned up when removed or when their owner or session closes.

Dialogs require Playwright. Handle them on arrival because an unresolved dialog blocks the triggering action:

session
|> once(Event.dialog(), fn dialog ->
  assert dialog.type == :confirm
  assert dialog.message == "Delete this item?"
  Dialog.accept(dialog)
end)
|> assert_event(Event.dialog(), &click(&1, by_role(:button, name: "Delete")))

Use Dialog.accept(dialog, "prompt text") for prompts or Dialog.dismiss(dialog). Types are :alert, :confirm, :prompt, and :beforeunload; metadata also includes message and default_value. Register a persistent catch-all handler when needed:

accept_dialog = &Dialog.accept/1
session
|> on(Event.dialog(), accept_dialog)
|> click(by_role(:button, name: "Delete first item"))
|> click(by_role(:button, name: "Delete second item"))
|> off(Event.dialog(), accept_dialog)

Without dialog listeners or waits, Playwright dismisses dialogs automatically. Once registered, handlers must resolve dialogs; a rejected predicate leaves the dialog unresolved. A wait observes without accepting or dismissing. Independent handlers and waits can observe the same dialog; ensure only one handler resolves it.

Requests and responses

Request and response events require Playwright. Page scope, the default, includes child frames; scope: :context observes all pages, including a popup's first request. Sources remain bound when switching pages. Use the same source and scope for off. Other events reject scope:: page events always observe the context, while popup, download, dialog, chooser, and frame-navigation events bind to the selected page.

session
|> assert_event(
  Event.response(&String.ends_with?(&1.url, "/api/reports")),
  &click(&1, by_role(:button, name: "Refresh")),
  fn response -> assert response.status == 200 end,
  scope: :context
)

Requests signal issuance; responses signal status and headers, without waiting for the response body. Values expose URL, method, headers, resource type, post data, page, and response status fields where applicable. The page can be nil when it is not yet known, such as for a popup's initial request.

File choosers

Locate a file input directly when possible, including hidden inputs. For a button whose JavaScript opens a chooser, register first and pass the returned value directly:

pending = wait_for(session, Event.file_chooser())
click(session, by_role(:button, name: "Choose portrait"))
chooser = await(pending)
assert FileChooser.multiple?(chooser) == false
set_input_files(session, chooser, "test/support/fixtures/portrait.png")

The chooser identifies its source page. Setting files preserves the current-page selection, even after switching elsewhere. This sets files programmatically; it does not operate the OS picker. Choosers require Playwright.

Cookies, storage, and browser interactions

Fluffy.Playwright.add_cookies/2 and Fluffy.Playwright.clear_cookies/2 return the session. Fluffy.Playwright.cookies/2 returns cookie maps; its optional urls: list filters by URL. Fluffy.Playwright.clear_cookies/2 accepts string or regex filters for name:, domain:, and path:. These helpers operate on the entire browser context.

Fluffy.Playwright.storage_state/2 returns a state map and optionally writes JSON with path:. Include IndexedDB with indexed_db: true. Reuse the result with start_session(:playwright, browser_context: [storage_state: state]).

For browser interaction, use Fluffy.Playwright.hover(session, locator), Fluffy.Playwright.drag_to(session, source, target), or Fluffy.Playwright.press_sequentially(session, locator, text, delay: 20). All accept timeout:. Sequential typing emits keyboard events for each character and appends at the current caret position. Drag source and target must belong to the same frame. These actions require Playwright and reconcile navigation caused by the action.