Current section
Files
Jump to
Current section
Files
docs/advanced-events.md
# Advanced events and pages
The examples assume the [test setup](usage.md#your-first-test), an initialized
`session`, and application routes and controls matching each scenario. Use
these imports and aliases after your ExUnit case module:
```elixir
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](usage.md#frames-playwright) 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](https://playwright.dev/docs/pages) and
[frames](https://playwright.dev/docs/frames) guides for the underlying concepts.
## Downloads
```elixir
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:
```elixir
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`:
```elixir
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.
```elixir
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](usage.md#page-assertions): query matching is
exact by default; use `query_mode: :subset` to allow additional parameter names.
```elixir
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:
```elixir
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
```elixir
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:
```elixir
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](usage.md#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.
```elixir
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.