# `PhoenixTestJsdom`
[🔗](https://github.com/ziinc/phoenix_test_jsdom/blob/v0.1.2/lib/phoenix_test_jsdom.ex#L1)

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\""

## Configuration

All settings are read from `config :phoenix_test_jsdom, ...` in `config/test.exs`.

| Option | Purpose |
|--------|---------|
| `node_path` | Absolute path to the Node binary. If unset, the worker tries `mise which node`, then `node` on `PATH`. |
| `setup_files` | List of CommonJS module paths (or a single string path) executed in each new JSDom window—polyfills, global mocks, test setup (similar to Vitest `setupFiles`). |
| `cwd` | Working directory for the Node process. Use this when scripts or `require()` must resolve packages from your app (for example your own `node_modules`). |

```elixir
# config/test.exs
config :phoenix_test_jsdom,
  node_path: "/opt/homebrew/bin/node",
  setup_files: [Path.expand("test/support/jsdom_setup.cjs", __DIR__)],
  cwd: Path.expand("../assets", __DIR__)
```

### Setup modes (global vs per-file)

**Global (recommended)** — start once before `ExUnit.start/0` so every test module shares one Node worker and request queue, while each mounted view or `PhoenixTestJsdom.Session` still gets its own JSDom instance id.

```elixir
# test/test_helper.exs
{:ok, _} = PhoenixTestJsdom.start()
ExUnit.start()
```

**Per-file (or per-module)** — start the supervisor under the test supervisor when only some tests need JSDom, or when you need different `setup_files` / `cwd` per test module (each `setup_all` run gets its own tree—heavier than global).

```elixir
defmodule MyApp.HeavyJsTest do
  use ExUnit.Case, async: true
  use MyAppWeb.ConnCase

  setup_all do
    start_supervised(PhoenixTestJsdom)
    :ok
  end

  # ...
end
```

Use **global** for the usual case (`async: true` across the suite). Use **per-file** when isolating optional JS-heavy tests.

### Async & isolation model

- **One** long-lived Node process (Erlang port) runs the bundled server; it multiplexes concurrent RPCs by request id.
- **Each** `mount/1` (LiveView) or `Session.new/1` (PhoenixTest) allocates a **separate** JSDom instance id, stored in `PhoenixTestJsdom.ViewRegistry` keyed by `{test_pid, view_id}`. Parallel async tests do not share DOM state.
- After server-driven updates (`render_click/2`, `render_patch/2`, `render_async/2`, etc.), HTML is **re-seeded** into the same JSDom id so the client bundle matches the LiveView-rendered markup. Pure client state that is not in the HTML snapshot is reset on each reseed—see the README note on LiveViewTest interop.

**Waiting for client-rendered DOM** — heavy widgets (Monaco, charts) often appear after `mount/1` returns. Block on a stable selector before asserting:

```elixir
{:ok, view, _} = live(conn, "/editor") |> PhoenixTestJsdom.mount()

view
|> PhoenixTestJsdom.wait_for(".monaco-editor", 10_000)
|> PhoenixTestJsdom.render()
```

With `PhoenixTestJsdom.Session` (PhoenixTest driver), `wait_for/3` returns the session for piping. The default timeout is 5000 ms. Lower-level callers can use `PhoenixTestJsdom.Jsdom.wait_for_selector/3` with an instance id (see the debug tests).

## User interactions & event firing

**LiveView + pipable helpers** (`PhoenixTestJsdom` on `%Phoenix.LiveViewTest.View{}`) dispatch real DOM events inside JSDom: `click/3`, `click_link/3`, `fill_in/3`, `select/3`, `check/3`, `uncheck/3`, `choose/3`, `submit/2`, `type/3`, etc. Options commonly include `:selector`, `:within`, and label-based matching as in PhoenixTest.

**Low-level DOM events** — `PhoenixTestJsdom.FireEvent` mirrors many DOM event names (`click/3`, `change/3`, `key_down/3`, …) and takes a `Phoenix.LiveViewTest.Element` from `element/2` or `element/3`. Use this when you need precise `Event` fields or events not wrapped by the pipable API.

Examples:

- [FireEvent and DOM coverage](https://github.com/tzeyiing/phoenix_test_jsdom/blob/main/test/phoenix_test_jsdom/fire_event_test.exs)
- [PhoenixTest session interactions](https://github.com/tzeyiing/phoenix_test_jsdom/blob/main/test/phoenix_test_jsdom/interactions_test.exs)
- [Per-file startup + `render_click` interop](https://github.com/tzeyiing/phoenix_test_jsdom/blob/main/test/phoenix_test_jsdom/plain_liveview_per_file_test.exs)

## Using with LiveViewTest

Obtain a view with `live/2` (or `live_isolated/3`), then call `mount/1` on the
`%Phoenix.LiveViewTest.View{}` or on the `{:ok, view, html}` tuple so scripts
run and the LiveView client can attach.

```elixir
import Phoenix.LiveViewTest

{:ok, view, _} = live(conn, "/counter") |> PhoenixTestJsdom.mount()

html =
  view
  |> PhoenixTestJsdom.click("Increment", selector: "button")
  |> PhoenixTestJsdom.render()

assert html =~ "Counter: 1"
```

Prefer **`PhoenixTestJsdom.render/1`** after interactions when you need the
DOM as JSDom sees it (including client-only updates). It falls back to
`Phoenix.LiveViewTest.render/1` when the view is not mounted in JSDom, so you
can share helpers between JS and non-JS tests.

When you drive the server with **`Phoenix.LiveViewTest`** (`render_click/2`,
`render_patch/2`, `render_hook/3`, etc.), use the **`PhoenixTestJsdom.render_*`**
wrappers on the view (same names, same arguments) instead of the plain
`LiveViewTest` versions whenever the view is JSDom-mounted: they run the
server handler, **re-seed** JSDom with the returned HTML, and return the HTML
string. That keeps LiveView and the in-memory DOM aligned after server-driven
updates.

```elixir
{:ok, view, _} = live(conn, "/counter") |> PhoenixTestJsdom.mount()

_html =
  PhoenixTestJsdom.render_click(view, selector: "button", text: "Increment")

assert PhoenixTestJsdom.render(view) =~ "Counter: 1"
```

### Using with LiveView Components

Use **`mount/3`** to render a single `Phoenix.LiveComponent` into JSDom without
navigating to a route. Pass the component module, assigns, and **`endpoint:`**
(required) so asset URLs resolve the same way as in app tests. The library
builds a synthetic `LiveViewTest.View` and seeds JSDom from
`render_component/2`. Pipe `PhoenixTestJsdom.render/1` and the interaction
helpers the same as for a full-page LiveView.

```elixir
view =
  PhoenixTestJsdom.mount(MyAppWeb.MyComponent, %{id: "c", value: 0},
    endpoint: MyAppWeb.Endpoint
  )

html =
  view
  |> PhoenixTestJsdom.click("Add", selector: "button")
  |> PhoenixTestJsdom.render()

assert html =~ "value=\"1\""
```

## Using with PhoenixTest

**`PhoenixTestJsdom.Session`** implements `PhoenixTest.Driver`. Build a
session with `Session.new(MyAppWeb.Endpoint)` (typically in a `setup` block),
`import PhoenixTest`, and pipe **`visit/2`**, **`click_button/2`**, **`fill_in/3`**,
**`assert_has/3`**, and the rest of the PhoenixTest API as usual—the driver
runs actions and assertions against JSDom instead of static HTML parsing.

**`PhoenixTestJsdom.wait_for/3`**, **`type/3`**, and **`exec_js/2`** are also
defined for `%PhoenixTestJsdom.Session{}` so you can wait on selectors, type
like a user, or evaluate snippets in the same window after PhoenixTest steps.

```elixir
import PhoenixTest

setup do
  {:ok, session: PhoenixTestJsdom.Session.new(MyAppWeb.Endpoint)}
end

test "counter via PhoenixTest + JSDom", %{session: session} do
  session
  |> visit("/react-counter")
  |> click_button("Increment")
  |> assert_has("#count-display", text: "Count: 1")
end
```

```elixir
session
|> visit("/search")
|> PhoenixTestJsdom.wait_for("#results", 5_000)
|> PhoenixTestJsdom.type("hello", selector: "input[name=q]")
|> assert_has("#results", text: "hello")
```

## Advanced

### Executing Custom JS

**`exec_js/2`** accepts a JavaScript string and runs it in the mounted window
(LiveView view or session). It returns **`{:ok, string}`** with the last
expression coerced to string, or **`{:error, message}`** on runtime errors.
Use this for small assertions, toggling feature flags in `window`, or calling
app globals when there is no dedicated Elixir helper.

```elixir
{:ok, width} =
  PhoenixTestJsdom.exec_js(view, "document.querySelector('#chart').clientWidth")

assert String.to_integer(width) > 0
```

```elixir
{:ok, _} =
  PhoenixTestJsdom.exec_js(session, "window.__FLAGS = { skipAnalytics: true }")
```

### Triggering custom events

For **`CustomEvent`** (or any non-generated RTL name), use
**`PhoenixTestJsdom.FireEvent.fire/4`**: pass the view, an `element/2` or
`element/3` selector, the event type string, and an optional detail map merged
into the event init (for example **`%{detail: %{id: 1}}`**). Pipe **`render/1`**
afterward to read the DOM. Server-side hooks still go through
**`render_hook/3`** (via the **`PhoenixTestJsdom.render_hook/3`** wrapper when
JSDom-mounted).

```elixir
import Phoenix.LiveViewTest, only: [element: 2]
alias PhoenixTestJsdom.FireEvent

html =
  view
  |> FireEvent.fire(element(view, "#sidebar"), "sidebar:toggle", %{detail: %{open: true}})
  |> PhoenixTestJsdom.render()

assert html =~ ~s(aria-expanded="true")
```

```elixir
_html = PhoenixTestJsdom.render_hook(view, :refresh, %{deg: 32})
```

### Mounting React components

Load your bundle from the LiveView template
or layout (for example a root hook that calls `createRoot`) and **`mount/1`**
the route or static page as for any other JS. JSDom executes the scripts, the
client connects over the test WebSocket, and **`click/3`** / **`wait_for/3`**
exercise the result. Point **`cwd`** at your assets tree if the bundle uses
`require` of packages from **`node_modules`**.

```elixir
{:ok, view, _} = live(conn, "/react-counter") |> PhoenixTestJsdom.mount()

html =
  view
  |> PhoenixTestJsdom.wait_for("#count-display", 5_000)
  |> PhoenixTestJsdom.click("Increment", selector: "button")
  |> PhoenixTestJsdom.render()

assert html =~ "Count: 1"
```

### Adding shims/stubs with setup files

Set **`setup_files`** in **`config :phoenix_test_jsdom`** to a list of CommonJS
paths. Each file runs once when a new JSDom window is created—before your page
scripts—so you can assign **`globalThis.fetch` mocks**, polyfills, etc.

Paths should be absolute (for example with **`Path.expand/2`**). Combine with **`cwd`** when modules need to resolve from
your app’s **`node_modules`**.

```elixir
# config/test.exs
config :phoenix_test_jsdom,
  setup_files: [Path.expand("test/support/jsdom_setup.cjs", __DIR__)],
  cwd: Path.expand("../assets", __DIR__)
```

```javascript
// test/support/jsdom_setup.cjs
globalThis.fetch = async () => ({
  ok: true,
  status: 200,
  json: async () => ({ items: [] })
});
```

# `async`

Waits for async server operations and re-mounts HTML in JSDom. Returns view.

# `check`

Checks a checkbox in JSDom and returns the view.

# `child_spec`

# `choose`

Chooses a radio button in JSDom and returns the view.

# `click`

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.

# `click_link`

Clicks a link in JSDom and returns the view.

Options: `:selector`, `:within`.

# `current_path`

Returns the current path from JSDom.

# `exec_js`

Evaluates JavaScript in the JSDom window and returns `{:ok, string_result}` or `{:error, msg}`.

# `fill_in`

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")

# `mount`

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()

# `mount`

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)

# `page_title`

Returns the page title from JSDom, or nil.

# `patch`

Patches the view to a new path (server + JSDom) and returns the view.

# `render`

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.

# `render_async`

Waits for async operations and re-mounts the HTML. Returns HTML.

# `render_blur`

Triggers a blur event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

# `render_change`

Triggers a change event and re-mounts the HTML. Returns HTML.

# `render_click`

Triggers a click event via LiveViewTest and re-mounts the resulting HTML into JSDom. Returns the HTML string.

# `render_component`

Renders a LiveComponent to HTML, mounts it in JSDom, and returns a synthetic view.

# `render_focus`

Triggers a focus event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

# `render_hook`

Triggers a hook push event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

# `render_keydown`

Triggers a keydown event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

# `render_keyup`

Triggers a keyup event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

# `render_patch`

Patches to a new path and re-mounts the HTML. Returns HTML.

# `render_submit`

Triggers a submit event and re-mounts the HTML. Returns HTML.

# `render_upload`

Advances an upload and re-mounts the HTML. Returns HTML.

# `select`

Selects an option in JSDom and returns the view. Use `:from` for the label.

# `start`

Same as `start_link/0`.

# `start_link`

Starts the PhoenixTestJsdom supervision tree. Call from test_helper.exs.

Configure via `config :phoenix_test_jsdom, key: value` in `config/test.exs`.

# `submit`

Submits a form in JSDom and returns the view. Options: `:selector`, `:within`.

# `type`

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.

# `uncheck`

Unchecks a checkbox in JSDom and returns the view.

# `unmount`

Destroys the JSDom instance for the view and removes it from the registry.

# `upload`

Advances an upload and returns the view.

# `wait_for`

Waits for a CSS selector to appear in JSDom and returns the view or session.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
