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.

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.

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.