# PhoenixTestDatastar
[](https://hex.pm/packages/phoenix_test_datastar/)
[](https://hexdocs.pm/phoenix_test_datastar/)
[](https://hex.pm/packages/phoenix_test_datastar)
A [PhoenixTest](https://hexdocs.pm/phoenix_test) driver for
[Dstar](https://hexdocs.pm/dstar)-powered Phoenix applications.
Write feature tests using the same `visit`, `click_button`, `fill_in`, and
`assert_has` API you already know — PhoenixTestDatastar handles the Datastar
parts: signals, SSE responses, and DOM patching.
```elixir
test "torpedo launch increments warhead count", %{conn: conn} do
conn
|> visit("/rocinante/weapons")
|> click_button("Fire torpedo")
|> assert_has("#warheads-remaining", text: "4")
end
```
Test real-time SSE streams too — open a long-lived connection, trigger server
events, and assert on the updates as they arrive:
```elixir
alias PhoenixTestDatastar.Stream
test "sensor dashboard updates on new contact", %{conn: conn} do
session =
conn
|> visit("/rocinante/sensors")
|> Stream.open_stream("/ds/sensor_handler/listen")
|> Stream.await_events()
assert_signal(session, "contacts", 0)
# Simulate a server-side event
Phoenix.PubSub.broadcast(Roci.PubSub, "sensors", {:contact_detected, 1})
session
|> Stream.await_events()
|> assert_signal("contacts", 1)
|> assert_has("#contact-count", text: "1")
|> Stream.close_stream()
end
```
No browser. No JavaScript runtime. Just ExUnit.
## Why?
Dstar brings [Datastar's](https://data-star.dev/) reactive UI to Phoenix via
Server-Sent Events. It's a different model from both static pages and LiveView:
| | Static Pages | LiveView | Dstar |
|--------------------|--------------------|-----------------------|--------------------------------|
| **Transport** | HTTP request/response | WebSocket | SSE over HTTP |
| **State** | Server sessions | Server process | Client-side signals |
| **Interaction** | Form submits | `phx-click` | `data-on:click="@post(...)"` |
| **DOM updates** | Full page reload | Diff patching via WS | SSE `patch-elements` events |
PhoenixTest's static driver understands HTML forms. Its Live driver understands
`phx-*` bindings. Neither understands `data-signals`, `@post()`, or SSE event
streams.
PhoenixTestDatastar is the third driver. It **simulates the Datastar JavaScript
client** inside your test process: maintaining signal state, dispatching HTTP
requests, parsing SSE responses, and applying patches to an in-memory DOM.
## Installation
Add `phoenix_test_datastar` to your test dependencies in `mix.exs`:
```elixir
def deps do
[
{:phoenix_test_datastar, "~> 0.0.1", only: :test, runtime: false}
]
end
```
### Configuration
PhoenixTestDatastar uses the same endpoint config as PhoenixTest. In
`config/test.exs`:
```elixir
config :phoenix_test, :endpoint, RociWeb.Endpoint
```
### Setup
Create a `DatastarCase` helper in `test/support/datastar_case.ex`:
```elixir
defmodule RociWeb.DatastarCase do
use ExUnit.CaseTemplate
using do
quote do
import PhoenixTest
import PhoenixTestDatastar
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(
Roci.Repo, shared: not tags[:async]
)
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
conn =
Phoenix.ConnTest.build_conn()
|> PhoenixTest.put_endpoint(RociWeb.Endpoint)
{:ok, conn: conn}
end
end
```
Then in your tests, use `PhoenixTestDatastar.visit/2` as the entry point:
```elixir
test "my test", %{conn: conn} do
conn
|> PhoenixTestDatastar.visit("/some-page")
|> click_button("Do something")
|> assert_has("#result", text: "Done")
end
```
> **Note:** Use `PhoenixTestDatastar.visit/2` instead of `PhoenixTest.visit/2`.
> This returns a `PhoenixTestDatastar.Session` struct that routes through the
> Datastar driver. All subsequent `click_button`, `fill_in`, `assert_has` etc.
> calls work through PhoenixTest's standard API.
## Usage
### Clicking buttons
Datastar buttons use `data-on:click="@post(...)"` instead of form submissions.
PhoenixTestDatastar detects these automatically:
```elixir
test "adjust reactor output", %{conn: conn} do
conn
|> visit("/rocinante/engineering")
|> click_button("Increase thrust")
|> click_button("Increase thrust")
|> assert_has("#reactor-output", text: "75%")
|> click_button("Decrease thrust")
|> assert_has("#reactor-output", text: "50%")
end
```
When you call `click_button`, the driver:
1. Finds the button in the DOM
2. Reads its `data-on:click` attribute (e.g., `@post('/ds/engineering_events/increase_thrust')`)
3. Builds a POST request with the current signals as JSON body
4. Dispatches through your endpoint
5. Parses the SSE response (`patch-signals`, `patch-elements`)
6. Applies patches to the in-memory DOM and signal state
Standard form buttons (without Datastar attributes) fall back to regular form
submission — so pages that mix Datastar and traditional forms work correctly.
### Filling in forms
Datastar inputs use `data-bind` to bind to signals. PhoenixTestDatastar handles
both signal-bound and traditional form inputs:
```elixir
test "search filters crew roster", %{conn: conn} do
conn
|> visit("/rocinante/crew")
|> fill_in("Search", with: "Holden")
|> click_button("Filter")
|> assert_has(".crew-member", text: "James Holden")
|> refute_has(".crew-member", text: "Amos Burton")
end
```
For `data-bind` inputs, `fill_in` updates the signal directly. For traditional
inputs, it tracks the value for form submission — same as PhoenixTest's static
driver.
### Assertions
All standard PhoenixTest assertions work:
```elixir
conn
|> visit("/ops/dashboard")
|> assert_has("h1", text: "OPS Dashboard")
|> assert_has("#crew-count", text: "4")
|> refute_has(".hull-breach")
|> assert_path("/ops/dashboard")
```
### Signal assertions
Import `PhoenixTestDatastar.Assertions` for signal-aware assertions:
```elixir
import PhoenixTestDatastar.Assertions
test "torpedo launch decrements warhead count", %{conn: conn} do
conn
|> visit("/rocinante/weapons")
|> assert_signal("warheads", 5)
|> assert_signal_set("warheads")
|> refute_signal("nonexistent")
|> click_button("Fire torpedo")
|> assert_signal("warheads", 4)
end
```
### Navigation and redirects
Dstar redirects work via `Dstar.redirect/2`, which sends a script that sets
`window.location.href`. The driver detects these and follows the redirect:
```elixir
test "login redirects to bridge", %{conn: conn} do
conn
|> visit("/login")
|> fill_in("Callsign", with: "holden@rocinante.belt")
|> fill_in("Access code", with: "donnager-7")
|> click_button("Authenticate")
|> assert_path("/bridge")
|> assert_has("h1", text: "Welcome aboard, Captain")
end
```
### Scoping with `within`
When a page has multiple forms or repeated elements, scope your interactions:
```elixir
test "repair specific ship system", %{conn: conn} do
conn
|> visit("/rocinante/damage-report")
|> within("#system-pdc-array", fn session ->
session
|> click_button("Repair")
end)
|> assert_has("#system-pdc-array.operational")
end
```
### Debugging with `open_browser`
Inspect the current DOM state in your browser:
```elixir
conn
|> visit("/rocinante/weapons")
|> click_button("Fire torpedo")
|> open_browser() # opens the current HTML in your default browser
|> click_button("Fire torpedo")
```
### Real-time SSE streams
For handlers that enter long-lived receive loops (e.g., PubSub-driven updates),
use the streaming API:
```elixir
alias PhoenixTestDatastar.Stream
test "live dashboard updates on sensor change", %{conn: conn} do
session =
conn
|> visit("/rocinante/sensors")
|> Stream.open_stream("/ds/sensor_handler/listen")
|> Stream.await_events()
assert_signal(session, "contacts", 0)
# Simulate external event (e.g., PubSub broadcast)
Phoenix.PubSub.broadcast(Roci.PubSub, "sensors", {:contact_detected, 1})
session
|> Stream.await_events()
|> assert_signal("contacts", 1)
|> Stream.close_stream()
end
```
### `data-init` auto-dispatching
When visiting a page with `data-init` attributes, the driver automatically
dispatches the init actions — just like the Datastar JS client would:
```elixir
# If the page has: <div data-init="@get('/ds/dashboard/load')">
test "dashboard loads initial data on visit", %{conn: conn} do
conn
|> visit("/dashboard") # data-init actions fire automatically
|> assert_has("#stats", text: "42")
end
```
### Escape hatch with `unwrap`
Access the raw conn when you need it:
```elixir
conn
|> visit("/rocinante/weapons")
|> unwrap(fn conn ->
# do something with the raw conn
conn
end)
```
## How it works
```
┌──────────────────────────────────────────────────────────────────┐
│ Test Code │
│ conn |> visit("/rocinante/weapons") |> click_button("Fire") │
│ |> assert_has("#warheads-remaining", text: "4") │
└────────────────────┬─────────────────────────────────────────────┘
│ PhoenixTest.Driver protocol
┌────────────────▼────────────────┐
│ PhoenixTestDatastar.Session │
│ │
│ • Signal Store (%{warheads: 5}) │
│ • DOM (in-memory HTML) │
│ • SSE Parser │
│ • Action Dispatcher │
└────────────────┬────────────────┘
│ Phoenix.ConnTest.dispatch
┌────────────────▼────────────────┐
│ Your Phoenix Endpoint │
│ Router → Dstar Handlers │
└─────────────────────────────────┘
```
On `visit/2`, the driver makes a standard GET request, extracts signals from
`data-signals` attributes, and stores the HTML — like pulling up the Roci's
tactical display.
On `click_button/2`, it finds the Datastar action expression, POSTs the current
signals as JSON, parses the SSE response, and applies `patch-signals` and
`patch-elements` events to update state — like the CIC processing a fire
command.
Assertions query the in-memory DOM — no network round-trip needed.
## Supported PhoenixTest API
PhoenixTestDatastar implements the full `PhoenixTest.Driver` protocol:
| Function | Datastar behavior |
|----------|-------------------|
| `visit/2` | GET request, extract signals from `data-signals` attributes |
| `click_button/2,3` | Detect `data-on:click`, dispatch `@post`/`@get`, apply SSE |
| `click_link/2,3` | Detect `data-on:click` or follow `href` |
| `fill_in/3,4` | Update signal (if `data-bind`) or track form value |
| `select/3,4` | Update signal or track selection |
| `check/2,3` | Update signal or track checkbox |
| `uncheck/2,3` | Update signal or track checkbox |
| `choose/2,3` | Update signal or track radio |
| `submit/1` | Submit active form |
| `within/3` | Scope to CSS selector |
| `assert_has/2,3` | Query in-memory DOM |
| `refute_has/2,3` | Query in-memory DOM |
| `assert_path/2,3` | Check current path |
| `refute_path/2,3` | Check current path |
| `open_browser/1` | Open HTML in system browser |
| `unwrap/2` | Access raw conn |
| `reload_page/1` | Re-visit current path |
### Datastar-specific API
| Function | Description |
|----------|-------------|
| `PhoenixTestDatastar.visit/2` | Entry point — creates Datastar session |
| `PhoenixTestDatastar.get_signal/2` | Read a signal value |
| `PhoenixTestDatastar.get_signals/1` | Read all signals |
| `PhoenixTestDatastar.put_signal/3` | Set a signal (for test setup) |
| `PhoenixTestDatastar.Assertions.assert_signal/3` | Assert signal value |
| `PhoenixTestDatastar.Assertions.assert_signal_set/2` | Assert signal exists |
| `PhoenixTestDatastar.Assertions.refute_signal/2` | Assert signal absent |
| `PhoenixTestDatastar.Stream.open_stream/2` | Open SSE stream connection |
| `PhoenixTestDatastar.Stream.await_events/1` | Wait for and apply SSE events |
| `PhoenixTestDatastar.Stream.close_stream/1` | Close SSE stream |
## Dependencies
- [phoenix_test](https://hex.pm/packages/phoenix_test) — Driver protocol and test helpers
- [phoenix](https://hex.pm/packages/phoenix) — ConnTest dispatching
- [floki](https://hex.pm/packages/floki) — DOM parsing and patching
- [jason](https://hex.pm/packages/jason) — JSON encoding/decoding
Dstar itself is **not** a dependency. The driver only understands the Datastar
SSE wire format and HTML attribute conventions. This keeps the packages loosely
coupled and means PhoenixTestDatastar works with any Elixir library that speaks
the Datastar protocol.
## License
MIT