README.md

# PhoenixHtmldriver

A lightweight Phoenix library for testing pure HTML interactions without the overhead of a headless browser. PhoenixHtmldriver provides a human-like API for testing Phoenix applications' HTML output, inspired by Capybara and Wallaby but optimized for pure HTML testing.

## Features

- **Session-based API**: Chain interactions naturally with a session object
- **Lightweight**: No headless browser overhead - pure HTML parsing with Floki
- **Phoenix Integration**: Seamlessly works with Phoenix.ConnTest
- **Human-readable**: Intuitive API that mirrors user interactions
- **Fast**: Significantly faster than browser-based testing

## Installation

Add `phoenix_htmldriver` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:phoenix_htmldriver, "~> 0.10.0"}
  ]
end
```

## Usage

### Basic Example with `use PhoenixHtmldriver` (Recommended)

The easiest way to use PhoenixHtmldriver is with the `use PhoenixHtmldriver` macro, which automatically configures the endpoint:

```elixir
defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  use PhoenixHtmldriver  # Automatically configures endpoint!
  alias PhoenixHtmldriver.{Form, Assertions}

  test "login flow", %{conn: conn} do
    # No manual setup needed - conn is automatically configured
    visit(conn, "/login")
    |> Form.new("#login-form")
    |> Form.fill(username: "alice", password: "secret")
    |> Form.submit()
    |> Assertions.assert_text("Welcome, alice")
    |> Assertions.assert_selector(".alert-success")
  end
end
```

**Important:** Make sure to set `@endpoint` **before** `use PhoenixHtmldriver`:

```elixir
defmodule MyTest do
  use ExUnit.Case

  @endpoint MyAppWeb.Endpoint  # Must come before use PhoenixHtmldriver
  use PhoenixHtmldriver

  # Tests...
end
```

### Manual Configuration (Advanced)

If you need more control, you can import functions directly:

```elixir
defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  import PhoenixHtmldriver
  alias PhoenixHtmldriver.{Form, Assertions}

  setup %{conn: conn} do
    conn = Plug.Conn.put_private(conn, :phoenix_endpoint, MyAppWeb.Endpoint)
    %{conn: conn}
  end

  test "login flow", %{conn: conn} do
    visit(conn, "/login")
    |> Form.new("#login-form")
    |> Form.fill(username: "alice", password: "secret")
    |> Form.submit()
    |> Assertions.assert_text("Welcome, alice")
  end
end
```

### Navigation

```elixir
alias PhoenixHtmldriver.{Session, Link}

# Visit a page
session = visit(conn, "/home")

# Click a link by selector
session =
  session
  |> Link.new("#about-link")
  |> Link.click()

# Click a link by text
session =
  session
  |> Link.new("About Us")
  |> Link.click()

# Get current path
path = Session.path(session)
```

### Forms

```elixir
alias PhoenixHtmldriver.Form

# Fill in a form and submit
session =
  session
  |> Form.new("#contact-form")
  |> Form.fill(
    name: "Alice",
    email: "alice@example.com",
    message: "Hello!"
  )
  |> Form.submit()

# Or chain it all together
session =
  session
  |> Form.new("#login-form")
  |> Form.fill(email: "user@example.com", password: "secret")
  |> Form.submit()

# Fill supports both keyword lists and maps
session =
  session
  |> Form.new("form")
  |> Form.fill(%{user: %{email: "test@example.com", password: "secret"}})
  |> Form.submit()

# Uncheck a checkbox
session =
  session
  |> Form.new("#preferences-form")
  |> Form.fill(newsletter: true)
  |> Form.uncheck(:newsletter)
  |> Form.submit()

# CSRF tokens are automatically extracted and included!
# No manual CSRF handling needed for forms with CSRF protection
```

### Assertions

```elixir
alias PhoenixHtmldriver.Assertions

# Assert text is present
session = Assertions.assert_text(session, "Welcome back")

# Assert element exists
session = Assertions.assert_selector(session, ".alert-success")
session = Assertions.assert_selector(session, "#user-profile")

# Assert element does not exist
session = Assertions.refute_selector(session, ".alert-danger")
```

### Finding Elements

```elixir
alias PhoenixHtmldriver.Element

# Find a single element (raises if not found)
element = Element.new(session, ".user-name")
text = Element.text(element)

# Get element attributes
element = Element.new(session, "#profile-link")
href = Element.attr(element, "href")
has_id = Element.has_attr?(element, "id")
```

### Inspecting Responses

```elixir
alias PhoenixHtmldriver.Session

# Get current HTML
html = Session.html(session)

# Get current path
path = Session.path(session)
```

## API Reference

### Core Module

- `PhoenixHtmldriver.visit/2` - Navigate to a path (creates new session from conn, or navigates within existing session)

### Session Module

- `Session.new(conn, path)` - Create a new session from a Plug.Conn
- `Session.get(session, path)` - Navigate to a path within an existing session
- `Session.path(session)` - Get current request path
- `Session.html(session)` - Get current response HTML

### Form Module

- `Form.new(session, selector)` - Create a form object from a CSS selector
- `Form.fill(form, values)` - Fill in form fields (accepts keyword list or map)
- `Form.uncheck(form, field)` - Uncheck a checkbox field
- `Form.submit(form)` - Submit the form and return a new session

### Link Module

- `Link.new(session, selector_or_text)` - Find a link by CSS selector or text content
- `Link.click(link)` - Click the link and return a new session

### Element Module

- `Element.new(session, selector)` - Find an element by CSS selector (raises if not found)
- `Element.text(element)` - Get element text content
- `Element.attr(element, name)` - Get attribute value
- `Element.has_attr?(element, name)` - Check if attribute exists

### Assertions Module

- `Assertions.assert_text(session, text)` - Assert text is present
- `Assertions.assert_selector(session, selector)` - Assert element exists
- `Assertions.refute_selector(session, selector)` - Assert element doesn't exist

## Session and Cookie Handling

PhoenixHtmldriver automatically preserves session cookies across requests, enabling you to test multi-step flows naturally:

```elixir
alias PhoenixHtmldriver.{Form, Link, Assertions}

test "login flow with session", %{conn: conn} do
  visit(conn, "/login")
  |> Form.new("#login-form")
  |> Form.fill(email: "user@example.com", password: "secret")
  |> Form.submit()
  |> Assertions.assert_text("Welcome back!")
  |> Link.new("Profile")
  |> Link.click()
  |> Assertions.assert_text("user@example.com")
  # Session cookies are automatically preserved throughout!
end
```

**How it works:**
- Session cookies from responses are automatically extracted
- Subsequent requests (`visit`, `click_link`, `submit_form`) include these cookies
- This enables proper session-based authentication and CSRF validation

## Automatic Redirect Following

PhoenixHtmldriver automatically follows HTTP redirects, just like a real browser:

```elixir
alias PhoenixHtmldriver.{Session, Form, Assertions}

test "form submission follows redirect", %{conn: conn} do
  session =
    visit(conn, "/login")
    |> Form.new("#login-form")
    |> Form.fill(email: "test@example.com", password: "secret")
    |> Form.submit()
    # Automatically follows 302 redirect to /dashboard

  assert Session.path(session) == "/dashboard"
  Assertions.assert_text(session, "Welcome back!")
end
```

**Features:**
- Automatically follows 301, 302, 303, 307, and 308 redirects
- Handles redirect chains (up to 5 redirects deep)
- Preserves cookies across redirects
- Works with `visit/2`, `Link.click/1`, and `Form.submit/1`
- `Session.path/1` returns the final destination after all redirects

## CSRF Protection

PhoenixHtmldriver automatically handles CSRF tokens for you! When submitting forms, it:

1. Looks for a hidden `_csrf_token` input field within the form
2. Falls back to a `<meta name="csrf-token">` tag in the document head
3. Automatically includes the token in POST, PUT, PATCH, and DELETE requests
4. Never overrides tokens you explicitly provide
5. **Works seamlessly with session cookies** to ensure tokens validate correctly

This means you can test forms with CSRF protection without any extra setup:

```elixir
alias PhoenixHtmldriver.{Form, Assertions}

test "login with CSRF protection", %{conn: conn} do
  visit(conn, "/login")
  |> Form.new("#login-form")
  |> Form.fill(email: "user@example.com", password: "secret")
  |> Form.submit()
  |> Assertions.assert_text("Welcome back!")
  # Both CSRF token AND session cookie were automatically handled!
end
```

## How It Works

PhoenixHtmldriver uses Floki to parse HTML and Plug.Test to simulate HTTP requests. Unlike browser-based testing tools, it works directly with your Phoenix application's conn struct, making tests fast and reliable.

The library maintains a Session struct that tracks:
- The current conn
- The parsed HTML document
- The latest response
- The endpoint being tested

This allows for natural chaining of interactions while maintaining the state of the "browsing session".

## Comparison with Other Tools

### vs. Wallaby/Hound (Browser-based)
- **Faster**: No browser startup overhead
- **Simpler**: No JavaScript support, pure HTML only
- **More Reliable**: No flaky browser interactions
- **Limited**: Cannot test JavaScript behavior

### vs. Phoenix.ConnTest (Direct)
- **More Natural**: Human-like API vs. low-level HTTP
- **Chainable**: Session-based interactions
- **HTML-aware**: Built-in selectors and assertions
- **Simpler Forms**: Easy form filling and submission

## When to Use

PhoenixHtmldriver is perfect for:
- Testing server-rendered HTML applications
- Controller and view testing
- Form submission flows
- Multi-step interactions
- Fast integration tests

It's not suitable for:
- Testing JavaScript-heavy applications
- Testing client-side interactions
- Testing WebSocket behavior

## Examples

### Testing a Multi-Step Flow

```elixir
alias PhoenixHtmldriver.{Form, Link, Assertions}

test "user registration and profile update", %{conn: conn} do
  # Register a new user
  session =
    visit(conn, "/register")
    |> Form.new("#registration-form")
    |> Form.fill(
      username: "alice",
      email: "alice@example.com",
      password: "secret123"
    )
    |> Form.submit()

  # Verify registration success
  session = Assertions.assert_text(session, "Welcome, alice!")

  # Navigate to profile
  session =
    session
    |> Link.new("Edit Profile")
    |> Link.click()

  # Update profile
  session
  |> Form.new("#profile-form")
  |> Form.fill(bio: "Hello, I'm Alice")
  |> Form.submit()
  |> Assertions.assert_text("Profile updated successfully")
end
```

### Testing with Assertions

```elixir
alias PhoenixHtmldriver.{Form, Assertions}

test "validates form submission", %{conn: conn} do
  visit(conn, "/contact")
  |> Form.new("#contact-form")
  |> Form.fill(name: "")  # Submit empty form
  |> Form.submit()
  |> Assertions.assert_text("Name is required")
  |> Assertions.assert_selector(".error-message")
  |> Assertions.refute_selector(".success-message")
end
```

## Setting Up in Your Tests

### Option 1: Using `use PhoenixHtmldriver` (Recommended)

The simplest way - just add `use PhoenixHtmldriver` after setting `@endpoint`:

```elixir
defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase

  @endpoint MyAppWeb.Endpoint  # Must come first!
  use PhoenixHtmldriver
  alias PhoenixHtmldriver.Assertions

  # No setup needed - conn is automatically configured!

  test "home page", %{conn: conn} do
    visit(conn, "/")
    |> Assertions.assert_text("Welcome")
  end
end
```

### Option 2: Manual Setup

If you need more control or prefer explicit configuration:

```elixir
defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase
  import PhoenixHtmldriver
  alias PhoenixHtmldriver.Assertions

  setup %{conn: conn} do
    conn = Plug.Conn.put_private(conn, :phoenix_endpoint, MyAppWeb.Endpoint)
    %{conn: conn}
  end

  test "home page", %{conn: conn} do
    visit(conn, "/")
    |> Assertions.assert_text("Welcome")
  end
end
```

## License

MIT

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.