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/2selects 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:
| Event | Captures the first new page… | Playwright equivalent |
|---|---|---|
Event.popup(:name) | opened by the current page | page.waitForEvent('popup') |
Event.page(:name) | opened anywhere in the session | context.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)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.
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.