Advanced events and pages

Copy Markdown View Source

The examples assume the test setup, an initialized session, and application routes and controls matching each scenario. Use these imports and aliases after your ExUnit case module:

use Fluffy.Assert

import Fluffy
import Fluffy.Locator

alias Fluffy.{Event, FileChooser, Page}

use Fluffy.Assert imports the assertion vocabulary. Page and captured-result constructors use target prefixes, such as page_url and response_status.

wait_for(Event.*(...), action) installs the listener before running the action. The capture timeout starts when arming; events arriving after it expires are ignored. Return the updated session from the callback. Captured results can be read repeatedly through their keys.

Browser terminology: tabs, windows, pages, and frames

Playwright uses these terms for browser UI elements:

  • Page: a browser tab or popup window. Fluffy gives captured pages names within a session; switch_page/2 selects the page for subsequent actions and assertions.
  • Popup: a new page opened by another page. This includes ordinary new tabs, not just separate popup windows.
  • Browser context: an isolated browser session. A Fluffy Playwright session owns one context, and its pages share cookies and storage.
  • Frame: a document within a page. Each page has a main frame; an HTML <iframe> embeds an additional frame, not another tab or window.

Fluffy supports switching between named pages and querying iframe contents with frame locators on the Playwright backend. switch_page/2 does not select an iframe, and ordinary Fluffy locators do not enter iframe documents. Frame locators scope element queries without changing the active page. Frame traversal is not supported by the Phoenix backend.

See Playwright's pages and frames guides for the underlying concepts.

Downloads

session
|> wait_for(Event.download(:report, filename: "potions.csv"), fn session ->
  click(session, by_role(:button, name: "Download potion ledger"))
end)
|> assert(download_suggested_filename(:report, "potions.csv"))
|> assert(download_content_type(:report, "text/csv"))

download(session, :report) returns %Fluffy.Download{} with filename, content_type, bytes, and url. Override the default retained-byte limit with max_bytes: on Event.download/2 or wait_for/4. Playwright uses the session timeout separately to save the captured download.

Use filename: (exact string or regex) and url: (absolute string, regex, or fn %URI{} -> boolean end) to select a download. Both filters must match; the first matching download is retained. Filtering happens before reading bytes or enforcing max_bytes:.

New pages and tabs (Playwright only)

Choose the event by which page can open the new tab or window:

EventCaptures the first new page…Playwright equivalent
Event.popup(:name)opened by the current pagepage.waitForEvent('popup')
Event.page(:name)opened anywhere in the sessioncontext.waitForEvent('page')

Here, “popup” includes an ordinary new tab, such as a target="_blank" link. Use popup when a click on the current page opens a tab:

session
|> wait_for(Event.popup(:secret_chamber), fn session ->
  click(session, by_role(:button, name: "Open chamber in new tab"))
end)
|> switch_page(:secret_chamber)
|> assert(page_opener(:main))
|> assert(page_url(path: "/chambers/secrets"))
|> assert(visible(by_role(:heading, name: "Chamber of Secrets")))
|> close_page()
|> assert(visible(by_text("Creature index")))

Use Event.page(:secret_chamber) in the same pattern when any new page in the session should match, including one created with the native browser context. For example, if another tab opens a page, page captures it; popup ignores it.

Both waits start listening before the callback runs. popup remains tied to the page that was active at that point, even if the callback switches pages. Captured pages get the session timeout to load and connect before becoming available. Use switch_page/2 to interact with them.

page(session, :secret_chamber) returns the captured Fluffy.Page without switching. Inspect it with Page.name/1, Page.url/1, Page.status/1, Page.opener/1, and Page.revision/1. The opener is its name in the session, or nil if there is no opener or it has not been captured.

All pages in one session share cookies and storage. Start separate sessions for independent users.

Phoenix follows links and submits forms in the current page, ignoring target and formtarget, including _blank. Both capture events, page switching, and closing pages require Playwright and raise a capability error in Phoenix.

Closing pages

close_page(session) closes the active page; close_page(session, :name) closes the named page. When closing the active page, the returned session targets its opener if that page is still open. Otherwise, it targets another remaining page, with no guaranteed selection order. Fluffy does not track page switching history. Closing an inactive page leaves the active page unchanged.

Closing an opener leaves the pages it opened alive. Closing the last page in a session raises ArgumentError.

In the example above, closing :secret_chamber returns to its opener, :main. When a particular target matters after closing, select it explicitly with switch_page/2:

session
|> close_page(:secret_chamber)
|> switch_page(:main)

Captures the first document navigation or URL change on both backends. Later navigations in the callback still update the active page. HTTP redirects contribute their final URL and status. Reloads count even when the URL stays the same. Requestless documents, such as about:blank, have no HTTP status.

session
|> wait_for(Event.navigation(:forbidden_forest), fn session ->
  click(session, by_role(:link, name: "Follow the spiders"))
end)
|> assert(navigation_url(:forbidden_forest, path: "/forest"))
|> assert(navigation_status(:forbidden_forest, 200))

Captured download, navigation, request, and response URL assertions accept exact strings, regexes, or structured path, query, and fragment matchers. Function predicates receive a %URI{}. This also applies to navigation_from_url/3. Strings are compared as captured, without resolving relative URLs. Structured matching follows the same rules as page URL assertions: query matching is exact by default; use query_mode: :subset to allow additional parameter names.

session
|> assert(
  navigation_url(:forbidden_forest,
    path: "/forest",
    query: %{"guide" => "spiders"},
    query_mode: :subset
  )
)

These assertions inspect the retained event result; they do not wait for the active page to change. wait_for/3 handles waiting for the event.

Dialogs

Dialogs are atomic browser-only events. The decision is installed before the action so an unhandled modal cannot deadlock the click:

session
|> wait_for(Event.dialog(:delete_recipe, decision: :accept), fn session ->
  click(session, by_role(:button, name: "Delete recipe"))
end)
|> assert(dialog_type(:delete_recipe, :confirm))
|> assert(dialog_message(:delete_recipe, "Delete this recipe?"))
|> assert(dialog_action(:delete_recipe, :accept))

Set decision: to :accept, :dismiss, {:accept, "prompt text"}, or a function receiving the unhandled %Fluffy.Dialog{}.

Requests and responses

session
|> wait_for(Event.response(:potions, ~r{/api/potions}), fn session ->
  click(session, by_role(:button, name: "Refresh potions"))
end)
|> assert(response_status(:potions, 200))
|> assert(response_resource_type(:potions, "fetch"))

Matchers can be an exact URL, regex, or normalized-event predicate. These are browser-wide subresource streams and therefore Playwright-only. The event contains method, URL, headers, resource type, post data, status fields, and the known page name.

File choosers

Directly locating the file input is preferred, even when it is hidden. Applications sometimes expose only a button whose JavaScript opens the input's native chooser. Capture that browser event before clicking, then pass its key to the same file-selection action:

session
|> wait_for(Event.file_chooser(:portrait), fn session ->
  click(session, by_role(:button, name: "Choose creature portrait"))
end)
|> set_input_files(:portrait, %Fluffy.FilePayload{
  name: "fluffy.png",
  bytes: File.read!("test/support/fixtures/fluffy.png"),
  content_type: "image/png"
})

file_chooser(session, :portrait) returns the opaque %Fluffy.FileChooser{}; FileChooser.multiple?/1 exposes whether it accepts multiple files. The chooser result is non-consuming and lives with the session, but it belongs to the page that opened it. Scripted chooser events are Playwright-only because Static and LiveView do not execute application JavaScript. This API sets files programmatically; it does not operate the OS picker UI.

See Browser diagnostics for traces, screenshots, and failure artifacts.

Creating pages and navigating history

Use new_page/2 to create and activate a named blank browser page. The name must not already identify an open page. The new page shares the session's cookies and storage and has no opener. switch_page/2 also brings the selected tab to the front.

session
|> new_page(:dashboard)
|> visit("/dashboard")
|> visit("/settings")
|> go_back()
|> go_forward()
|> switch_page(:main)

go_back/2 and go_forward/2 accept timeout: and return the updated session. They leave the browser at its current URL when there is no history entry. These operations require Playwright. close_session/1 is available for both backends and releases the session's resources early, returning :ok or {:error, reason}. Do not reuse a closed session.

Cookies, storage, and browser interactions

Fluffy.Playwright.add_cookies/2 and clear_cookies/2 return the session. cookies/2 returns cookie maps; its optional urls: list filters by URL. 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 hover(session, locator), drag_to(session, source, target), or 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.