Packages

A Phoenix LiveView testing library that uses jsdom for full JavaScript integration testing

Current section

Files

Jump to
phoenix_test_jsdom lib phoenix_test_jsdom.ex
Raw

lib/phoenix_test_jsdom.ex

defmodule PhoenixTestJsdom do
@moduledoc """
A view-centric JSDom bridge for Phoenix LiveView testing.
Mount a live view into a JSDom instance so client-side JavaScript (React
hooks, etc.) executes alongside the live server-side process:
{:ok, view, _html} = live(conn, "/react-counter")
view = PhoenixTestJsdom.mount(view)
html =
view
|> PhoenixTestJsdom.click("Increment", selector: "button")
|> PhoenixTestJsdom.render()
assert html =~ "Count: 1"
Or use the pass-through tuple form:
{:ok, view, html} = live(conn, "/react-counter") |> PhoenixTestJsdom.mount()
## Interaction functions
All interaction functions return the view so you can pipe them:
view
|> PhoenixTestJsdom.click("Submit")
|> PhoenixTestJsdom.render()
The `render_*` variants mirror `Phoenix.LiveViewTest.render_*` names and
return the HTML string directly, for compatibility with existing assertions:
html = PhoenixTestJsdom.render_click(view, "button", "Increment")
assert html =~ "Count: 1"
## Isolated LiveComponents
Render a standalone LiveComponent into JSDom without a live URL:
view = PhoenixTestJsdom.mount(MyComponent, %{id: "c", value: 0},
endpoint: MyAppWeb.Endpoint)
assert PhoenixTestJsdom.render(view) =~ "value=\\"0\\""
## Setup
Call `PhoenixTestJsdom.start/0` in `test/test_helper.exs`:
{:ok, _} = PhoenixTestJsdom.start()
ExUnit.start()
## Configuration
config :phoenix_test_jsdom, node_path: "/path/to/node"
"""
alias PhoenixTestJsdom.{Jsdom, ViewRegistry}
alias Phoenix.LiveViewTest
require Phoenix.LiveViewTest
# ---------------------------------------------------------------------------
# Supervisor
# ---------------------------------------------------------------------------
@doc """
Starts the PhoenixTestJsdom supervision tree. Call from test_helper.exs.
Options:
- `:setup_files` — list of paths to CJS modules invoked on every JSDom window (like vitest setupFiles)
- `:cwd` — working directory for the Node.js process (enables resolving user-installed npm packages)
Options can also be set via `config :phoenix_test_jsdom, key: value` in config/test.exs.
"""
defdelegate start_link(opts \\ []), to: PhoenixTestJsdom.Supervisor
defdelegate child_spec(opts), to: PhoenixTestJsdom.Supervisor
@doc "Same as `start_link/1`; convenient alias used in sample `test_helper.exs` files."
def start(opts \\ []), do: start_link(opts)
# ---------------------------------------------------------------------------
# Mount
# ---------------------------------------------------------------------------
@doc """
Mounts a LiveViewTest view (or `{:ok, view, html}` tuple) into a JSDom
instance and returns the view.
The pass-through tuple form is convenient when chaining directly after `live/2`:
{:ok, view, html} = live(conn, "/react-counter") |> PhoenixTestJsdom.mount()
"""
def mount({:ok, %LiveViewTest.View{} = view, html}) do
mounted = do_mount_view(view, nil)
{:ok, mounted, html}
end
def mount({:error, _} = err), do: err
def mount(%LiveViewTest.View{} = view) do
do_mount_view(view, nil)
end
@doc """
Renders an isolated LiveComponent into a JSDom instance and returns a
synthetic view.
Requires `:endpoint` in `opts` to derive the base URL for script resolution.
view = PhoenixTestJsdom.mount(MyComponent, %{id: "c"}, endpoint: MyEndpoint)
"""
def mount(component, assigns \\ %{}, opts \\ []) when is_atom(component) do
endpoint =
opts[:endpoint] ||
raise ArgumentError,
"PhoenixTestJsdom.mount/3 for a LiveComponent requires :endpoint option"
html = LiveViewTest.render_component(component, assigns)
synthetic_view = %LiveViewTest.View{pid: self(), id: make_ref() |> inspect(), endpoint: endpoint}
do_mount_view(synthetic_view, html)
end
defp do_mount_view(%LiveViewTest.View{proxy: nil} = view, html) do
id = generate_id()
url = base_url(view.endpoint) <> "/"
:ok = Jsdom.mount_html(id, wrap_document(html), url)
ViewRegistry.put(registry_key(view), id)
view
end
defp do_mount_view(%LiveViewTest.View{} = view, _html) do
# The proxy stores the URL with the default test conn host (www.example.com),
# so we rebase the path onto the actual endpoint base URL.
{:ok, proxy_url} = GenServer.call(elem(view.proxy, 2), :url)
%{path: path, query: query} = URI.parse(proxy_url)
url = base_url(view.endpoint) <> path <> if(query, do: "?#{query}", else: "")
id = generate_id()
:ok = Jsdom.visit(id, url)
ViewRegistry.put(registry_key(view), id)
view
end
# ---------------------------------------------------------------------------
# Inspect (read from JSDom)
# ---------------------------------------------------------------------------
@doc """
Returns the current HTML for the view.
When the view has a JSDom instance (mounted via `mount/1`), returns the
JSDom HTML — reflecting client-side mutations like React hook renders.
Falls back to `Phoenix.LiveViewTest.render/1` for views not mounted in JSDom,
so importing `render/1` from this module works for both JSDom and plain LV
tests without needing to distinguish them at the call site.
"""
def render(%LiveViewTest.View{} = view) do
case ViewRegistry.fetch(view) do
{:ok, id} ->
{:ok, html} = Jsdom.get_html(id)
html
:error ->
LiveViewTest.render(view)
end
end
@doc "Returns the page title from JSDom, or nil."
def page_title(%LiveViewTest.View{} = view) do
id = ViewRegistry.fetch!(view)
case Jsdom.get_title(id) do
{:ok, ""} -> nil
{:ok, title} -> title
_ -> nil
end
end
@doc "Returns the current path from JSDom."
def current_path(%LiveViewTest.View{} = view) do
id = ViewRegistry.fetch!(view)
{:ok, path} = Jsdom.get_current_path(id)
path
end
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@doc "Destroys the JSDom instance for the view and removes it from the registry."
def unmount(%LiveViewTest.View{} = view) do
ViewRegistry.delete(registry_key(view))
view
end
# ---------------------------------------------------------------------------
# render_* wrappers (return HTML, mirror LiveViewTest names)
# ---------------------------------------------------------------------------
@doc "Triggers a click event via LiveViewTest and re-mounts the resulting HTML into JSDom. Returns the HTML string."
def render_click(view_or_element, value_or_opts \\ %{})
def render_click(%LiveViewTest.View{} = view, opts) when is_list(opts) do
element = resolve_element(view, opts)
html = LiveViewTest.render_click(element)
reseed!(view, html)
html
end
def render_click(%LiveViewTest.View{} = view, value) do
html = LiveViewTest.render_click(view, value)
reseed!(view, html)
html
end
def render_click(%LiveViewTest.Element{} = element, value) do
LiveViewTest.render_click(element, value)
end
@doc "Triggers a submit event and re-mounts the HTML. Returns HTML."
def render_submit(view_or_element, value \\ %{})
def render_submit(%LiveViewTest.View{} = view, value) do
html = LiveViewTest.render_submit(view, value)
reseed!(view, html)
html
end
def render_submit(%LiveViewTest.Element{} = element, value) do
LiveViewTest.render_submit(element, value)
end
@doc "Triggers a change event and re-mounts the HTML. Returns HTML."
def render_change(view_or_element, value \\ %{})
def render_change(%LiveViewTest.View{} = view, value) do
html = LiveViewTest.render_change(view, value)
reseed!(view, html)
html
end
def render_change(%LiveViewTest.Element{} = element, value) do
LiveViewTest.render_change(element, value)
end
@doc "Triggers a keydown event and re-mounts the HTML into JSDom when called with a view. Returns HTML."
def render_keydown(view_or_element, event, value \\ %{})
def render_keydown(%LiveViewTest.View{} = view, event, value) do
html = LiveViewTest.render_keydown(view, event, value)
reseed!(view, html)
html
end
def render_keydown(%LiveViewTest.Element{} = element, event, value) do
LiveViewTest.render_keydown(element, event, value)
end
@doc "Triggers a keyup event and re-mounts the HTML into JSDom when called with a view. Returns HTML."
def render_keyup(view_or_element, event, value \\ %{})
def render_keyup(%LiveViewTest.View{} = view, event, value) do
html = LiveViewTest.render_keyup(view, event, value)
reseed!(view, html)
html
end
def render_keyup(%LiveViewTest.Element{} = element, event, value) do
LiveViewTest.render_keyup(element, event, value)
end
@doc "Triggers a blur event and re-mounts the HTML into JSDom when called with a view. Returns HTML."
def render_blur(view_or_element, event, value \\ %{})
def render_blur(%LiveViewTest.View{} = view, event, value) do
html = LiveViewTest.render_blur(view, event, value)
reseed!(view, html)
html
end
def render_blur(%LiveViewTest.Element{} = element, event, value) do
LiveViewTest.render_blur(element, event, value)
end
@doc "Triggers a focus event and re-mounts the HTML into JSDom when called with a view. Returns HTML."
def render_focus(view_or_element, event, value \\ %{})
def render_focus(%LiveViewTest.View{} = view, event, value) do
html = LiveViewTest.render_focus(view, event, value)
reseed!(view, html)
html
end
def render_focus(%LiveViewTest.Element{} = element, event, value) do
LiveViewTest.render_focus(element, event, value)
end
@doc "Triggers a hook push event and re-mounts the HTML into JSDom when called with a view. Returns HTML."
def render_hook(view_or_element, event, value \\ %{})
def render_hook(%LiveViewTest.View{} = view, event, value) do
html = LiveViewTest.render_hook(view, event, value)
reseed!(view, html)
html
end
def render_hook(%LiveViewTest.Element{} = element, event, value) do
LiveViewTest.render_hook(element, event, value)
end
@doc "Patches to a new path and re-mounts the HTML. Returns HTML."
def render_patch(%LiveViewTest.View{} = view, path) do
html = LiveViewTest.render_patch(view, path)
reseed!(view, html)
html
end
@doc "Waits for async operations and re-mounts the HTML. Returns HTML."
def render_async(%LiveViewTest.View{} = view, timeout \\ 200) do
html = LiveViewTest.render_async(view, timeout)
reseed!(view, html)
html
end
@doc "Advances an upload and re-mounts the HTML. Returns HTML."
def render_upload(%LiveViewTest.Upload{} = upload, entry_name, percent \\ 100) do
view = upload.view
html = LiveViewTest.render_upload(upload, entry_name, percent)
reseed!(view, html)
html
end
@doc "Renders a LiveComponent to HTML, mounts it in JSDom, and returns a synthetic view."
def render_component(component, assigns \\ %{}, opts \\ []) when is_atom(component) do
mount(component, assigns, opts)
end
# ---------------------------------------------------------------------------
# Pipable interaction functions (return view)
# ---------------------------------------------------------------------------
@doc """
Clicks a button in JSDom and returns the view.
This dispatches the click directly in the JSDom instance, so React-rendered
buttons and other client-side elements are found and clicked. Any LiveView
`pushEvent` fired by the click is handled by the LV client running inside JSDom.
Options:
- `:selector` — CSS selector restricting where to look (e.g. `"button"`)
- `:within` — scope selector
The text argument filters by button label.
"""
def click(%LiveViewTest.View{} = view, text, opts \\ []) do
id = ViewRegistry.fetch!(view)
selector = opts[:selector]
within = opts[:within]
:ok = Jsdom.click_button(id, selector, text, within)
view
end
@doc """
Clicks a link in JSDom and returns the view.
Options: `:selector`, `:within`.
"""
def click_link(%LiveViewTest.View{} = view, text, opts \\ []) do
id = ViewRegistry.fetch!(view)
selector = opts[:selector] || "a"
within = opts[:within]
:ok = Jsdom.click_link(id, selector, text, within)
view
end
@doc """
Fills in an input in JSDom and returns the view.
Options: `:selector`, `:within`. Use `:with` for the value.
PhoenixTestJsdom.fill_in(view, "Email", with: "hello@example.com")
"""
def fill_in(%LiveViewTest.View{} = view, label, opts) do
id = ViewRegistry.fetch!(view)
value = Keyword.fetch!(opts, :with) |> to_string()
selector = opts[:selector]
within = opts[:within]
:ok = Jsdom.fill_in(id, selector, label, value, within)
view
end
@doc "Submits a form in JSDom and returns the view. Options: `:selector`, `:within`."
def submit(%LiveViewTest.View{} = view, opts \\ []) do
id = ViewRegistry.fetch!(view)
selector = opts[:selector] || "form"
within = opts[:within]
:ok = Jsdom.submit_form(id, selector, within)
view
end
@doc "Selects an option in JSDom and returns the view. Use `:from` for the label."
def select(%LiveViewTest.View{} = view, option, opts) do
id = ViewRegistry.fetch!(view)
label = Keyword.fetch!(opts, :from)
selector = opts[:selector]
within = opts[:within]
:ok = Jsdom.select_option(id, selector, label, option, within)
view
end
@doc "Checks a checkbox in JSDom and returns the view."
def check(%LiveViewTest.View{} = view, label, opts \\ []) do
id = ViewRegistry.fetch!(view)
:ok = Jsdom.check(id, opts[:selector], label, opts[:within])
view
end
@doc "Unchecks a checkbox in JSDom and returns the view."
def uncheck(%LiveViewTest.View{} = view, label, opts \\ []) do
id = ViewRegistry.fetch!(view)
:ok = Jsdom.uncheck(id, opts[:selector], label, opts[:within])
view
end
@doc "Chooses a radio button in JSDom and returns the view."
def choose(%LiveViewTest.View{} = view, label, opts \\ []) do
id = ViewRegistry.fetch!(view)
:ok = Jsdom.choose(id, opts[:selector], label, opts[:within])
view
end
@doc "Waits for a CSS selector to appear in JSDom and returns the view or session."
def wait_for(view_or_session, selector, timeout \\ 5000)
def wait_for(%LiveViewTest.View{} = view, selector, timeout) do
id = ViewRegistry.fetch!(view)
:ok = Jsdom.wait_for_selector(id, selector, timeout)
view
end
def wait_for(%PhoenixTestJsdom.Session{} = session, selector, timeout) do
:ok = Jsdom.wait_for_selector(session.instance_id, selector, timeout)
session
end
@doc """
Types text into the focused element (or the element matching `selector:` if given).
Dispatches keydown/input/keyup events per character, mimicking user keyboard input.
Use `\\n` to insert a newline / press Enter.
Call `click/3` first to ensure an element is focused, or pass `selector:` directly.
Returns the view or session for piping.
"""
def type(view_or_session, text, opts \\ [])
def type(%LiveViewTest.View{} = view, text, opts) when is_binary(text) do
id = ViewRegistry.fetch!(view)
:ok = Jsdom.type_text(id, text, opts[:selector])
view
end
def type(%PhoenixTestJsdom.Session{} = session, text, opts) when is_binary(text) do
:ok = Jsdom.type_text(session.instance_id, text, opts[:selector])
session
end
@doc "Evaluates JavaScript in the JSDom window and returns `{:ok, string_result}` or `{:error, msg}`."
def exec_js(%LiveViewTest.View{} = view, code) do
id = ViewRegistry.fetch!(view)
Jsdom.exec_js(id, code)
end
def exec_js(%PhoenixTestJsdom.Session{} = session, code) do
Jsdom.exec_js(session.instance_id, code)
end
@doc "Patches the view to a new path (server + JSDom) and returns the view."
def patch(%LiveViewTest.View{} = view, path) do
render_patch(view, path)
view
end
@doc "Waits for async server operations and re-mounts HTML in JSDom. Returns view."
def async(%LiveViewTest.View{} = view, timeout \\ 200) do
render_async(view, timeout)
view
end
@doc "Advances an upload and returns the view."
def upload(upload, entry_name, percent \\ 100) do
render_upload(upload, entry_name, percent)
upload.view
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp base_url(endpoint) do
%{scheme: scheme, host: host, port: port} = endpoint.struct_url()
"#{scheme}://#{host}:#{port}"
end
defp wrap_document(html) do
"<!DOCTYPE html><html><head></head><body>#{html}</body></html>"
end
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
defp registry_key(%LiveViewTest.View{pid: pid, id: id}), do: {pid, id}
defp reseed!(%LiveViewTest.View{} = view, html) do
id = ViewRegistry.fetch!(view)
url = base_url(view.endpoint) <> "/"
:ok = Jsdom.patch_html(id, html, url)
end
defp resolve_element(%LiveViewTest.View{} = view, opts) do
selector = opts[:selector] || raise ArgumentError, "expected :selector option"
text = opts[:text]
if text, do: LiveViewTest.element(view, selector, text), else: LiveViewTest.element(view, selector)
end
end