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.
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)
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")))Captured popups get the session timeout to load and connect before they become available.
Multiple pages and tabs require Playwright. Phoenix follows links and submits
forms in its current page, ignoring target and formtarget, including
_blank. Popup capture, page switching, and closing pages raise a capability
error in Phoenix. Multiple isolated sessions remain supported by both backends.
All pages inside one Playwright session share cookies and storage; independent
sessions do not.
page(session, :secret_chamber) returns the captured opaque Fluffy.Page; use
Page.name/1, Page.url/1, Page.status/1, Page.opener/1, and
Page.revision/1 to inspect its metadata without switching.
Navigation
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.